Compare commits

..

9 Commits

Author SHA1 Message Date
Nikhil Soni
1e326159b0 feat(tracedetail): add waterfall api with memory optimisations (#11450)
Some checks are pending
build-staging / prepare (push) Waiting to run
Release Drafter / update_release_draft (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
* feat: add store methods for minimal trace fetch

* feat: break down waterfall module to handle large spans

Handling large traces in two steps to avoid high
memory allocation

* refactor: keep the waterfall changes in new api version

This is to avoid the contract change in existing v3

* chore: avoid unnecessary diffs

* refactor: move conversion logic to types

* chore: update openapi specs

* refactor: use sqlbuider for queries

* chore: fix comment

* chore: avoid passing request type to module

* refactor: avoid passing whole summary object around

* chore: remove trace_id from querying since its already known

* chore: remove unused reference column from query

* chore: update openapi specs
2026-05-26 10:11:16 +00:00
Nityananda Gohain
ceb1b4871b feat: trace based filters for logs, supporting aggregations as well (#11394)
* feat: trace based filters for logs, supporting aggregations as well

* fix: update comments

* fix: cleanup query from tests

* fix: address comments

* fix: address comments

---------

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2026-05-26 09:57:18 +00:00
Abhi kumar
d48a238e15 chore: broke down drilldown navigate into a saperate hook (#11070)
* chore: broke down drilldown navigate into a saperate hook

* chore: fmt fix
2026-05-26 06:16:37 +00:00
Abhi kumar
2ca6ff7719 test: added test for crosshair series highlight changes (#11015)
* chore: added changes for crosshair sync for tooltip

* chore: minor cleanup

* chore: updated the core structure

* chore: updated the types

* chore: minor cleanup

* feat: added changes for sereis highlighting on crosshair sync

* test: added test for crosshair series highlight changes

* chore: pr review fixes

* chore: handled other cases of groupby

* chore: updated tests
2026-05-26 06:09:52 +00:00
swapnil-signoz
0671c5f416 feat: installed integration dashboards migration to DB (#11415)
* chore: added migration setup

* feat(sqlmigration): add integration_dashboards table (migration 079)

Adds the `integration_dashboards` relations table that stores the
integration-specific identity for dashboards provisioned from cloud
or builtin integrations. Columns: id, org_id, dashboard_id, provider,
slug, created_at, updated_at. Includes a unique index on dashboard_id.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(sqlmigration): backfill cloud integration dashboards to DB (migration 080)

One-time idempotent migration that provisions dashboard rows for all
orgs with existing cloud integration services where metrics are enabled.
Each dashboard is inserted into the `dashboard` table with
source="integration" and locked=true, and a companion row is added to
`integration_dashboards` with provider="cloud_integrations" and
slug="{provider}-{service}-{dashboard}" (e.g. aws-alb-overview).
Idempotency is enforced by checking (org_id, provider, slug) on
integration_dashboards before each insert.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(sqlmigration): clean up stale 079 artifacts, add 079 schema migration

Remove the pre-rename 079_migrate_cloud_integration_dashboards.go and
079_cloud_integration_dashboards/ directory that were left behind when
the backfill migration was renumbered to 080. Add the missing
079_add_integration_dashboards.go (schema-only migration creating the
integration_dashboards table) which provider.go already references.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: adding comment for fk

* refactor: renaming table name

* refactor: rename and restructure cloud integration dashboard migration types

* chore: file rename

* refactor: dashboard creation and listing flow change

* refactor: removing loose strings

* refactor: adding DeleteBySource on dashboard module

* refactor: review changes and update service flow change

* refactor: simplify comments

* ci: lint staticcheck fix

* refactor: renaming migration and adding integration tests

* ci: py fmt lint fixes

* feat: adding ListSharedServices store method

* ci: golangci-lint fix

* feat(integrations): persist installed integration dashboards in DB

Provisions dashboard DB rows when an integration is installed and
deprovisions them on uninstall. Adds a backfill migration (087) for
users with already-installed integrations. Removes the on-the-fly
filesystem serving path from http_handler in favor of the standard
dashboard module.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor: changing dashboard ID and other cleanup

* chore: update code structure for better readability and maintainability

* refactor: removing deprecated cloud integrations and merging
integration types

* refactor: renaming migration files and removing deprecated tests

* refactor: using BunDBCtx method instead

* ci: fix py fmt lint

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 05:42:34 +00:00
Aditya Singh
33b455406a feat: right dock span details (#11427)
* feat: right dock span details

* feat: reorder options

* feat: style fix

* feat: refactor resize boc
2026-05-26 04:17:12 +00:00
Abhishek Kumar Singh
804ea2a7f8 feat: alert template processor + integration in notifiers (#10750)
Some checks failed
build-staging / prepare (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
* chore: custom notifiers in alert manager

* chore: lint fixs

* chore: fix email linter

* chore: added tracing to msteamsv2 notifier

* feat: alert manager template to template title and notification body

* chore: updated test name + code for timeout errors

* chore: added utils for using variables with $ notation

* chore: exposed templates for alertmanager types

* feat: added preprocessor for alert templater

* chore: hooked preProcess function in expandTitle and body, added labels and annotations in alertdata

* chore: fix lint issues

* chore: added handling for missing variable used in template

* feat: converted alerttemplater to interface and updated tests

* refactor: added extractCommonKV instead of 2 different functions

* test: fix preprocessor test case

* feat: added support for  and  in templating

* chore: lint fix

* chore: renamed the interface

* chore: added test for missing function

* refactor: test case and sb related changed

* refactor: comments and test improvements

* chore: lint fix

* chore: updated comments

* feat: added basic html markdown templater

* chore: updated newline to markdown format

* feat: slack blockkit renderer using goldmark

* test: added test for html rendering

* feat: integrated slack blockit in markdownrenderer package and removed plaintext format

* chore: updated br with new line in test and logs added

* refactor: alert manager templater

* feat: added no-op formatter in markdown rederer

* chore: return missing variables as sorted list

* feat: alert notification processor

* chore: refactor notification processor and send processor in ReceiverIntegrations

* chore: return isDefaultTemplated true even in case of blank default template

* feat: updated email notifier

* feat: update ms team notifier with notification processor

* refactor: ms teams notifier

* chore: msteams note

* feat: added notification processor in opsgenie notifier

* feat: added notification processor in slack notifier

* feat: added notification processor in pagerduty notifier

* chore: added IsCustomTemplated helper function in result struct

* feat: added notification processor in webhook notifier

* chore: updated alertmanagernotify package with updated notifier signature

* feat: slack mrkdwn renderer

* feat: added new format in markdown renderer

* test: simplify TestRenderSlackMrkdwn

* test: add new test cases for Slack MRKDWN rendering

* feat: updated slack notifier with slack mrkdwn format

* fix: webhook notifier update annotations before preparing data

* fix: added handling for labels and annotations with `.` and `-`

* fix: handled <no value> in templated response

* test: added test in notification procesor for no value

* refactor: review comments

* refactor: lint fixes

* chore: updated licenses for notifiers

* chore: updated email notifier from upstream

* chore: lint fixes

* feat: added no value extension to render <no value> in html

* feat: email rendering with custom template in notification processor

* chore: integration of custom templating in rule manager

* chore: added action links to email and slack notifiers

* chore: fix linter and merge conflict issues

* feat: added `Literal` for CompareOperator and MatchType and expose from ruleManager

* chore: error logging + NoOp type definition

* feat: return single templating result from  with flag for template type

* fix: variables with symbols in template

* feat: slack mrkdwn renderer

* feat: custom raw html renderer to escape <no value>

* chore: integrated slack mrkdwn renderer and added NoOp formatter

* fix: email template directory for notification processor

* chore: remove static templates from pagerduty notifications

* chore: removed notifier test files

* fix: concurrent rendering in markdown renderer

* refactor: changes as per internal review

* chore: lint issue

* chore: removed special handling for softline break

* refactor: removed logger as markdown renderer dependency

* refactor: changed markdown renderer from interface to package-level functions

* refactor: changes as per internal review

* chore: removed notification processor

* chore: updated webhook notifier to send templated title and body in notification

* refactor: msteams skip logs and traces as factsset, slack code refactor

* chore: remove private annotations from pagerduty notifier

* chore: updated email template based on new template struct

* chore: update receiver integrations

* chore: outdated comment

* chore: move to templates/alertmanager

* chore: address comments

* chore: add example for templates

---------

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2026-05-25 17:07:55 +00:00
Jatinderjit Singh
a3a7fc4081 feat(planned-downtime): explicit toggle for all vs specific alert rules (#11272)
* feat(planned-downtime): explicit toggle for all vs specific alert rules

Replace the implicit "empty alert list silences everything" behavior
with a Radio toggle ("All alert rules" / "Specific alert rules") so
users can't accidentally silence every alert by forgetting to select
rules. The list view now displays an explicit "All alert rules" tag
instead of a dash for schedules that silence everything.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: remove redundant messaging

* chore: reuse existing variable

* chore: fix typo

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2026-05-25 16:54:46 +00:00
Vinicius Lourenço
3c8c318925 chore(upgrade-signoz): removed upgrade.signoz.io url (#11449) 2026-05-25 16:20:42 +00:00
213 changed files with 23755 additions and 67573 deletions

View File

@@ -11,7 +11,7 @@ RUN apk update && \
COPY ./target/${OS}-${TARGETARCH}/signoz-community /root/signoz
COPY ./templates/email /root/templates
COPY ./templates /root/templates
COPY frontend/build/ /etc/signoz/web/
RUN chmod 755 /root /root/signoz

View File

@@ -12,7 +12,7 @@ RUN apk update && \
rm -rf /var/cache/apk/*
COPY ./target/${OS}-${ARCH}/signoz-community /root/signoz-community
COPY ./templates/email /root/templates
COPY ./templates /root/templates
COPY frontend/build/ /etc/signoz/web/
RUN chmod 755 /root /root/signoz-community

View File

@@ -11,7 +11,7 @@ RUN apk update && \
COPY ./target/${OS}-${TARGETARCH}/signoz /root/signoz
COPY ./templates/email /root/templates
COPY ./templates /root/templates
COPY frontend/build/ /etc/signoz/web/
RUN chmod 755 /root /root/signoz

View File

@@ -26,7 +26,7 @@ RUN go mod download
COPY ./cmd/ ./cmd/
COPY ./ee/ ./ee/
COPY ./pkg/ ./pkg/
COPY ./templates/email /root/templates
COPY ./templates /root/templates
COPY Makefile Makefile
RUN TARGET_DIR=/root ARCHS=${TARGETARCH} ZEUS_URL=${ZEUSURL} LICENSE_URL=${ZEUSURL}/api/v1 make go-build-enterprise-race

View File

@@ -12,7 +12,7 @@ RUN apk update && \
rm -rf /var/cache/apk/*
COPY ./target/${OS}-${ARCH}/signoz /root/signoz
COPY ./templates/email /root/templates
COPY ./templates /root/templates
COPY frontend/build/ /etc/signoz/web/
RUN chmod 755 /root /root/signoz

View File

@@ -35,7 +35,7 @@ RUN go mod download
COPY ./cmd/ ./cmd/
COPY ./ee/ ./ee/
COPY ./pkg/ ./pkg/
COPY ./templates/email /root/templates
COPY ./templates /root/templates
COPY Makefile Makefile
RUN TARGET_DIR=/root ARCHS=${TARGETARCH} ZEUS_URL=${ZEUSURL} LICENSE_URL=${ZEUSURL}/api/v1 make go-build-enterprise-race

View File

@@ -182,6 +182,11 @@ alertmanager:
poll_interval: 1m
# The URL under which Alertmanager is externally reachable (for example, if Alertmanager is served via a reverse proxy). Used for generating relative and absolute links back to Alertmanager itself.
external_url: http://localhost:8080
# The list of globs from which SigNoz's alertmanager notification templates are loaded (e.g. the email.signoz.html layout).
# This mirrors the upstream alertmanager `templates` config option. The upstream default templates (default.tmpl, email.tmpl)
# are always loaded from the embedded alertmanager assets, so only SigNoz's own templates need to be listed here.
templates:
- /opt/signoz/conf/templates/alertmanager/*.gotmpl
# The global configuration for the alertmanager. All the exahustive fields can be found in the upstream: https://github.com/prometheus/alertmanager/blob/efa05feffd644ba4accb526e98a8c6545d26a783/config/config.go#L833
global:
# ResolveTimeout is the time after which an alert is declared resolved if it has not been updated.

View File

@@ -5672,14 +5672,6 @@ components:
required:
- items
type: object
SpantypesGettableTraceAggregations:
properties:
aggregations:
items:
$ref: '#/components/schemas/SpantypesSpanAggregationResult'
nullable: true
type: array
type: object
SpantypesGettableWaterfallTrace:
properties:
aggregations:
@@ -5745,14 +5737,6 @@ components:
- name
- condition
type: object
SpantypesPostableTraceAggregations:
properties:
aggregations:
items:
$ref: '#/components/schemas/SpantypesSpanAggregation'
nullable: true
type: array
type: object
SpantypesPostableWaterfall:
properties:
aggregations:
@@ -11380,75 +11364,6 @@ paths:
summary: Test notification channel (deprecated)
tags:
- channels
/api/v1/traces/{traceID}/aggregations:
post:
deprecated: false
description: Computes span aggregations grouped by requested field.
operationId: GetTraceAggregations
parameters:
- in: path
name: traceID
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/SpantypesPostableTraceAggregations'
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/SpantypesGettableTraceAggregations'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: Get aggregations for a trace
tags:
- tracedetail
/api/v1/user:
get:
deprecated: false

View File

@@ -535,7 +535,7 @@ func (module *module) getOrCreateAPIKey(ctx context.Context, orgID valuer.UUID,
func (module *module) provisionDashboards(ctx context.Context, orgID valuer.UUID, createdBy string, creator valuer.UUID, provider cloudintegrationtypes.CloudProviderType, service *cloudintegrationtypes.CloudIntegrationService, serviceDefinition *cloudintegrationtypes.ServiceDefinition) error {
// TODO: DB calls are in for loop, can be optimized later.
for _, dashboard := range serviceDefinition.Assets.Dashboards {
slug := cloudintegrationtypes.IntegrationDashboardSlug(provider, service.Type, dashboard.ID)
slug := cloudintegrationtypes.CloudIntegrationDashboardSlug(provider, service.Type, dashboard.ID)
existing, err := module.store.GetIntegrationDashboardBySlug(ctx, orgID, cloudintegrationtypes.IntegrationDashboardProviderCloudIntegration, slug)
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
@@ -562,7 +562,7 @@ func (module *module) provisionDashboards(ctx context.Context, orgID valuer.UUID
// deprovisionDashboards deletes all dashboard and integration_dashboard rows for the given service.
// make sure to call this within a transaction.
func (module *module) deprovisionDashboards(ctx context.Context, orgID valuer.UUID, provider cloudintegrationtypes.CloudProviderType, serviceID cloudintegrationtypes.ServiceID) error {
slugPrefix := cloudintegrationtypes.IntegrationDashboardSlugPrefix(provider, serviceID)
slugPrefix := cloudintegrationtypes.CloudIntegrationDashboardSlugPrefix(provider, serviceID)
rows, err := module.store.ListIntegrationDashboardsBySlugPrefix(ctx, orgID, cloudintegrationtypes.IntegrationDashboardProviderCloudIntegration, slugPrefix)
if err != nil {
return err
@@ -588,7 +588,7 @@ func (module *module) deprovisionDashboards(ctx context.Context, orgID valuer.UU
// TODO: remove this hack and send idiomatic response to client.
func (module *module) enrichDashboardIDs(ctx context.Context, orgID valuer.UUID, provider cloudintegrationtypes.CloudProviderType, serviceID cloudintegrationtypes.ServiceID, serviceDefinition *cloudintegrationtypes.ServiceDefinition) error {
for i, d := range serviceDefinition.Assets.Dashboards {
slug := cloudintegrationtypes.IntegrationDashboardSlug(provider, serviceID, d.ID)
slug := cloudintegrationtypes.CloudIntegrationDashboardSlug(provider, serviceID, d.ID)
row, err := module.store.GetIntegrationDashboardBySlug(ctx, orgID, cloudintegrationtypes.IntegrationDashboardProviderCloudIntegration, slug)
if err != nil {
if errors.Ast(err, errors.TypeNotFound) {

View File

@@ -9,7 +9,6 @@ import (
"github.com/SigNoz/signoz/pkg/global"
"github.com/SigNoz/signoz/pkg/http/middleware"
baseapp "github.com/SigNoz/signoz/pkg/query-service/app"
"github.com/SigNoz/signoz/pkg/query-service/app/cloudintegrations"
"github.com/SigNoz/signoz/pkg/query-service/app/integrations"
"github.com/SigNoz/signoz/pkg/query-service/app/logparsingpipeline"
"github.com/SigNoz/signoz/pkg/query-service/interfaces"
@@ -24,7 +23,6 @@ type APIHandlerOptions struct {
DataConnector interfaces.Reader
UsageManager *usage.Manager
IntegrationsController *integrations.Controller
CloudIntegrationsController *cloudintegrations.Controller
LogsParsingPipelineController *logparsingpipeline.LogParsingPipelineController
GatewayUrl string
// Querier Influx Interval
@@ -42,7 +40,6 @@ func NewAPIHandler(opts APIHandlerOptions, signoz *signoz.SigNoz, config signoz.
baseHandler, err := baseapp.NewAPIHandler(baseapp.APIHandlerOpts{
Reader: opts.DataConnector,
IntegrationsController: opts.IntegrationsController,
CloudIntegrationsController: opts.CloudIntegrationsController,
LogsParsingPipelineController: opts.LogsParsingPipelineController,
FluxInterval: opts.FluxInterval,
LicensingAPI: httplicensing.NewLicensingAPI(signoz.Licensing),
@@ -91,17 +88,6 @@ func (ah *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
}
func (ah *APIHandler) RegisterCloudIntegrationsRoutes(router *mux.Router, am *middleware.AuthZ) {
ah.APIHandler.RegisterCloudIntegrationsRoutes(router, am)
router.HandleFunc(
"/api/v1/cloud-integrations/{cloudProvider}/accounts/generate-connection-params",
am.EditAccess(ah.CloudIntegrationsGenerateConnectionParams),
).Methods(http.MethodGet)
}
func (ah *APIHandler) getVersion(w http.ResponseWriter, r *http.Request) {
versionResponse := basemodel.GetVersionResponse{
Version: version.Info.Version(),

View File

@@ -30,7 +30,6 @@ import (
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
baseapp "github.com/SigNoz/signoz/pkg/query-service/app"
"github.com/SigNoz/signoz/pkg/query-service/app/clickhouseReader"
"github.com/SigNoz/signoz/pkg/query-service/app/cloudintegrations"
"github.com/SigNoz/signoz/pkg/query-service/app/integrations"
"github.com/SigNoz/signoz/pkg/query-service/app/logparsingpipeline"
"github.com/SigNoz/signoz/pkg/query-service/app/opamp"
@@ -86,20 +85,13 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
// initiate opamp
opAmpModel.Init(signoz.SQLStore, signoz.Instrumentation.Logger(), signoz.Modules.OrgGetter)
integrationsController, err := integrations.NewController(signoz.SQLStore)
integrationsController, err := integrations.NewController(signoz.SQLStore, signoz.Modules.Dashboard)
if err != nil {
return nil, fmt.Errorf(
"couldn't create integrations controller: %w", err,
)
}
cloudIntegrationsController, err := cloudintegrations.NewController(signoz.SQLStore)
if err != nil {
return nil, fmt.Errorf(
"couldn't create cloud provider integrations controller: %w", err,
)
}
// ingestion pipelines manager
logParsingPipelineController, err := logparsingpipeline.NewLogParsingPipelinesController(
signoz.SQLStore,
@@ -134,7 +126,6 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
DataConnector: reader,
UsageManager: usageManager,
IntegrationsController: integrationsController,
CloudIntegrationsController: cloudIntegrationsController,
LogsParsingPipelineController: logParsingPipelineController,
FluxInterval: config.Querier.FluxInterval,
GatewayUrl: config.Gateway.URL.String(),
@@ -200,7 +191,6 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h
apiHandler.RegisterRoutes(r, am)
apiHandler.RegisterLogsRoutes(r, am)
apiHandler.RegisterIntegrationRoutes(r, am)
apiHandler.RegisterCloudIntegrationsRoutes(r, am)
apiHandler.RegisterQueryRangeV3Routes(r, am)
apiHandler.RegisterInfraMetricsRoutes(r, am)
apiHandler.RegisterQueryRangeV4Routes(r, am)

View File

@@ -6775,13 +6775,6 @@ export interface SpantypesSpanAggregationResultDTO {
value?: SpantypesSpanAggregationResultDTOValue;
}
export interface SpantypesGettableTraceAggregationsDTO {
/**
* @type array,null
*/
aggregations?: SpantypesSpanAggregationResultDTO[] | null;
}
export type SpantypesWaterfallSpanDTOAttributesAnyOf = {
[key: string]: unknown;
};
@@ -7025,13 +7018,6 @@ export interface SpantypesSpanAggregationDTO {
field?: TelemetrytypesTelemetryFieldKeyDTO;
}
export interface SpantypesPostableTraceAggregationsDTO {
/**
* @type array,null
*/
aggregations?: SpantypesSpanAggregationDTO[] | null;
}
export interface SpantypesPostableWaterfallDTO {
/**
* @type array,null
@@ -8365,17 +8351,6 @@ export type UpdateSpanMapperPathParameters = {
groupId: string;
mapperId: string;
};
export type GetTraceAggregationsPathParameters = {
traceID: string;
};
export type GetTraceAggregations200 = {
data: SpantypesGettableTraceAggregationsDTO;
/**
* @type string
*/
status: string;
};
export type ListUsersDeprecated200 = {
/**
* @type array

View File

@@ -12,120 +12,17 @@ import type {
} from 'react-query';
import type {
GetTraceAggregations200,
GetTraceAggregationsPathParameters,
GetWaterfall200,
GetWaterfallPathParameters,
GetWaterfallV4200,
GetWaterfallV4PathParameters,
RenderErrorResponseDTO,
SpantypesPostableTraceAggregationsDTO,
SpantypesPostableWaterfallDTO,
} from '../sigNoz.schemas';
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
/**
* Computes span aggregations grouped by requested field.
* @summary Get aggregations for a trace
*/
export const getTraceAggregations = (
{ traceID }: GetTraceAggregationsPathParameters,
spantypesPostableTraceAggregationsDTO?: BodyType<SpantypesPostableTraceAggregationsDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetTraceAggregations200>({
url: `/api/v1/traces/${traceID}/aggregations`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: spantypesPostableTraceAggregationsDTO,
signal,
});
};
export const getGetTraceAggregationsMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof getTraceAggregations>>,
TError,
{
pathParams: GetTraceAggregationsPathParameters;
data?: BodyType<SpantypesPostableTraceAggregationsDTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof getTraceAggregations>>,
TError,
{
pathParams: GetTraceAggregationsPathParameters;
data?: BodyType<SpantypesPostableTraceAggregationsDTO>;
},
TContext
> => {
const mutationKey = ['getTraceAggregations'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof getTraceAggregations>>,
{
pathParams: GetTraceAggregationsPathParameters;
data?: BodyType<SpantypesPostableTraceAggregationsDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return getTraceAggregations(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type GetTraceAggregationsMutationResult = NonNullable<
Awaited<ReturnType<typeof getTraceAggregations>>
>;
export type GetTraceAggregationsMutationBody =
| BodyType<SpantypesPostableTraceAggregationsDTO>
| undefined;
export type GetTraceAggregationsMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get aggregations for a trace
*/
export const useGetTraceAggregations = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof getTraceAggregations>>,
TError,
{
pathParams: GetTraceAggregationsPathParameters;
data?: BodyType<SpantypesPostableTraceAggregationsDTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof getTraceAggregations>>,
TError,
{
pathParams: GetTraceAggregationsPathParameters;
data?: BodyType<SpantypesPostableTraceAggregationsDTO>;
},
TContext
> => {
return useMutation(getGetTraceAggregationsMutationOptions(options));
};
/**
* Returns the waterfall view of spans for a given trace ID with tree structure, metadata, and windowed pagination
* @summary Get waterfall view for a trace

View File

@@ -10,9 +10,6 @@ export const DEFAULT_AUTH0_APP_REDIRECTION_PATH = ROUTES.APPLICATION;
export const INVITE_MEMBERS_HASH = '#invite-team-members';
export const SIGNOZ_UPGRADE_PLAN_URL =
'https://upgrade.signoz.io/upgrade-from-app';
export const DASHBOARD_TIME_IN_DURATION = 'refreshInterval';
export const DEFAULT_ENTITY_VERSION = 'v3';

View File

@@ -1,4 +1,3 @@
import { SIGNOZ_UPGRADE_PLAN_URL } from 'constants/app';
import CreateAlertChannels from 'container/CreateAlertChannels';
import { ChannelType } from 'container/CreateAlertChannels/config';
import {
@@ -313,16 +312,6 @@ describe('Create Alert Channel (Normal User)', () => {
expect(screen.getByText('Microsoft Teams')).toBeInTheDocument();
});
it.skip('Should check if the upgrade plan message is shown', () => {
expect(screen.getByText('Upgrade to a Paid Plan')).toBeInTheDocument();
expect(
screen.getByText(/This feature is available for paid plans only./),
).toBeInTheDocument();
const link = screen.getByRole('link', { name: 'Click here' });
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute('href', SIGNOZ_UPGRADE_PLAN_URL);
expect(screen.getByText(/to Upgrade/)).toBeInTheDocument();
});
it('Should check if the form buttons are displayed properly (Save, Test, Back)', () => {
expect(
screen.getByRole('button', { name: 'button_save_channel' }),

View File

@@ -0,0 +1,282 @@
import { Widgets } from 'types/api/dashboard/getAll';
import {
MetricRangePayloadProps,
MetricRangePayloadV3,
} from 'types/api/metrics/getQueryRange';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { PanelMode } from '../../types';
import { prepareBarPanelConfig } from '../utils';
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
jest.mock(
'container/DashboardContainer/visualization/panels/utils/legendVisibilityUtils',
() => ({
getStoredSeriesVisibility: jest.fn(),
}),
);
jest.mock('lib/uPlotLib/plugins/onClickPlugin', () => ({
__esModule: true,
default: jest.fn().mockReturnValue({ name: 'onClickPlugin' }),
}));
jest.mock('lib/dashboard/getQueryResults', () => ({
getLegend: jest.fn(
(_queryData: unknown, _query: unknown, labelName: string) =>
`legend-${labelName}`,
),
}));
jest.mock('lib/getLabelName', () => ({
__esModule: true,
default: jest.fn(
(_metric: unknown, _queryName: string, _legend: string) => 'baseLabel',
),
}));
jest.mock(
'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils',
() => ({
getInitialStackedBands: jest.fn().mockReturnValue([]),
}),
);
const getLegendMock = jest.requireMock('lib/dashboard/getQueryResults')
.getLegend as jest.Mock;
const getLabelNameMock = jest.requireMock('lib/getLabelName')
.default as jest.Mock;
const getInitialStackedBandsMock = jest.requireMock(
'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils',
).getInitialStackedBands as jest.Mock;
const createApiResponse = (
result: MetricRangePayloadProps['data']['result'] = [],
): MetricRangePayloadProps => ({
data: {
result,
resultType: 'matrix',
newResult: null as unknown as MetricRangePayloadV3,
},
});
const createWidget = (overrides: Partial<Widgets> = {}): Widgets =>
({
id: 'widget-1',
yAxisUnit: 'ms',
isLogScale: false,
thresholds: [],
customLegendColors: {},
...overrides,
}) as Widgets;
const defaultTimezone = {
name: 'UTC',
value: 'UTC',
offset: 'UTC',
searchIndex: 'UTC',
};
describe('BarPanel utils', () => {
beforeEach(() => {
jest.clearAllMocks();
getLabelNameMock.mockReturnValue('baseLabel');
getLegendMock.mockImplementation(
(_queryData: unknown, _query: unknown, labelName: string) =>
`legend-${labelName}`,
);
});
describe('prepareBarPanelData', () => {
it('returns aligned data with timestamps and empty series when result is empty', () => {
const data = prepareChartData(createApiResponse([]));
expect(data).toHaveLength(1);
expect(data[0]).toStrictEqual([]);
});
it('returns timestamps and one series of y values for single series', () => {
const data = prepareChartData(
createApiResponse([
{
metric: {},
queryName: 'Q',
legend: 'Series A',
values: [
[1000, '10'],
[2000, '20'],
],
} as MetricRangePayloadProps['data']['result'][0],
]),
);
expect(data).toHaveLength(2);
expect(data[0]).toStrictEqual([1000, 2000]);
expect(data[1]).toStrictEqual([10, 20]);
});
it('merges timestamps and fills missing values with null for multiple series', () => {
const data = prepareChartData(
createApiResponse([
{
metric: {},
queryName: 'Q1',
values: [
[1000, '1'],
[3000, '3'],
],
} as MetricRangePayloadProps['data']['result'][0],
{
metric: {},
queryName: 'Q2',
values: [
[1000, '10'],
[2000, '20'],
],
} as MetricRangePayloadProps['data']['result'][0],
]),
);
expect(data[0]).toStrictEqual([1000, 2000, 3000]);
expect(data[1]).toStrictEqual([1, null, 3]);
expect(data[2]).toStrictEqual([10, 20, null]);
});
});
describe('prepareBarPanelConfig', () => {
const baseParams = {
widget: createWidget(),
isDarkMode: true,
currentQuery: {} as Query,
onClick: jest.fn(),
onDragSelect: jest.fn(),
apiResponse: createApiResponse(),
timezone: defaultTimezone,
panelMode: PanelMode.DASHBOARD_VIEW,
};
it('adds no series when apiResponse has empty result', () => {
const config = prepareBarPanelConfig(baseParams).getConfig();
expect(config.series).toHaveLength(1);
});
it('adds one series per result item', () => {
const apiResponse = createApiResponse([
{
metric: {},
queryName: 'Q1',
values: [[1000, '1']],
} as MetricRangePayloadProps['data']['result'][0],
{
metric: {},
queryName: 'Q2',
values: [[1000, '2']],
} as MetricRangePayloadProps['data']['result'][0],
]);
const config = prepareBarPanelConfig({
...baseParams,
apiResponse,
}).getConfig();
expect(config.series).toHaveLength(3);
});
it('uses getLegend for label when currentQuery is provided', () => {
const apiResponse = createApiResponse([
{
metric: {},
queryName: 'Q1',
legend: 'L1',
values: [[1000, '1']],
} as MetricRangePayloadProps['data']['result'][0],
]);
const config = prepareBarPanelConfig({
...baseParams,
apiResponse,
currentQuery: {} as Query,
}).getConfig();
expect(getLegendMock).toHaveBeenCalled();
expect(config.series?.[1]).toMatchObject({ label: 'legend-baseLabel' });
});
it('uses getLabelName for label when currentQuery is null', () => {
getLegendMock.mockReset();
const apiResponse = createApiResponse([
{
metric: { __name__: 'requests' },
queryName: 'Q1',
values: [[1000, '1']],
} as MetricRangePayloadProps['data']['result'][0],
]);
prepareBarPanelConfig({
...baseParams,
apiResponse,
currentQuery: null as unknown as Query,
});
expect(getLabelNameMock).toHaveBeenCalled();
expect(getLegendMock).not.toHaveBeenCalled();
});
it('passes result metric to each series for cross-panel sync', () => {
const metric = { host: 'server1', __name__: 'http_requests' };
const apiResponse = createApiResponse([
{
metric,
queryName: 'Q1',
values: [[1000, '1']],
} as MetricRangePayloadProps['data']['result'][0],
]);
const config = prepareBarPanelConfig({
...baseParams,
apiResponse,
}).getConfig();
expect(config.series?.[1]).toMatchObject({ metric });
});
it('uses widget customLegendColors for series stroke', () => {
const widget = createWidget({
customLegendColors: { 'legend-baseLabel': '#ff0000' },
});
const apiResponse = createApiResponse([
{
metric: {},
queryName: 'Q',
values: [[1000, '1']],
} as MetricRangePayloadProps['data']['result'][0],
]);
const config = prepareBarPanelConfig({
...baseParams,
widget,
apiResponse,
}).getConfig();
expect(config.series?.[1]).toMatchObject({ stroke: '#ff0000' });
});
it('calls getInitialStackedBands when widget is stackedBarChart', () => {
const widget = createWidget({ stackedBarChart: true });
const apiResponse = createApiResponse([
{
metric: {},
queryName: 'Q1',
values: [[1000, '1']],
} as MetricRangePayloadProps['data']['result'][0],
{
metric: {},
queryName: 'Q2',
values: [[1000, '2']],
} as MetricRangePayloadProps['data']['result'][0],
]);
prepareBarPanelConfig({ ...baseParams, widget, apiResponse });
// seriesCount = result.length + 1 = 3
expect(getInitialStackedBandsMock).toHaveBeenCalledWith(3);
});
it('does not call getInitialStackedBands for non-stacked chart', () => {
const apiResponse = createApiResponse([
{
metric: {},
queryName: 'Q1',
values: [[1000, '1']],
} as MetricRangePayloadProps['data']['result'][0],
]);
prepareBarPanelConfig({ ...baseParams, apiResponse });
expect(getInitialStackedBandsMock).not.toHaveBeenCalled();
});
});
});

View File

@@ -303,6 +303,27 @@ describe('TimeSeriesPanel utils', () => {
expect(seriesConfig!.stroke).toBe('#ff0000');
});
it('passes result metric to each series for cross-panel sync', () => {
const metric = { host: 'server1', __name__: 'cpu' };
const apiResponse = createApiResponse([
{
metric,
queryName: 'Q',
values: [
[1000, '1'],
[2000, '2'],
],
} as MetricRangePayloadProps['data']['result'][0],
]);
const config = prepareUPlotConfig({
...baseParams,
apiResponse,
}).getConfig();
expect(config.series?.[1]).toMatchObject({ metric });
});
it('adds multiple series when result has multiple items', () => {
const apiResponse = createApiResponse([
{

View File

@@ -81,6 +81,20 @@
}
}
.alert-rule-scope {
margin-bottom: 12px;
.ant-radio-wrapper {
color: var(--l1-foreground);
}
}
.alert-rule-all-warning {
font-size: 12px;
font-weight: 400;
color: var(--l2-foreground);
}
.formItemWithBullet {
margin-bottom: 0;
}

View File

@@ -8,6 +8,7 @@ import {
FormInstance,
Input,
Modal,
Radio,
Select,
SelectProps,
Spin,
@@ -71,11 +72,14 @@ const TZ_OPTIONS: DefaultOptionType[] = ALL_TIME_ZONES.map(
}),
);
type AlertRuleScope = 'all' | 'specific';
interface PlannedDowntimeFormData {
name: string;
startTime: dayjs.Dayjs | null;
endTime: dayjs.Dayjs | null;
recurrence?: AlertmanagertypesRecurrenceDTO;
alertRuleScope: AlertRuleScope;
alertRules: DefaultOptionType[];
recurrenceSelect?: AlertmanagertypesRecurrenceDTO;
timezone?: string;
@@ -129,6 +133,12 @@ export function PlannedDowntimeForm(
recurrenceOptions.doesNotRepeat.value,
);
const [alertRuleScope, setAlertRuleScope] = useState<AlertRuleScope>(
initialValues.id && (initialValues.alertIds || []).length === 0
? 'all'
: 'specific',
);
const { notifications } = useNotifications();
const { showErrorModal } = useErrorModal();
@@ -142,9 +152,12 @@ export function PlannedDowntimeForm(
const saveHandler = useCallback(
async (values: PlannedDowntimeFormData) => {
const data: AlertmanagertypesPostablePlannedMaintenanceDTO = {
alertIds: values.alertRules
.map((alert) => alert.value)
.filter((alert) => alert !== undefined) as string[],
alertIds:
values.alertRuleScope === 'all'
? []
: (values.alertRules
.map((alert) => alert.value)
.filter((alert) => alert !== undefined) as string[]),
name: values.name,
scope: values.scope,
schedule: {
@@ -265,12 +278,13 @@ export function PlannedDowntimeForm(
const startTime = schedule?.recurrence?.startTime || schedule?.startTime;
const endTime = schedule?.recurrence?.endTime || schedule?.endTime;
const initialAlertIds = initialValues.alertIds || [];
return {
name: defaultTo(initialValues.name, ''),
alertRules: getAlertOptionsFromIds(
initialValues.alertIds || [],
alertOptions,
),
alertRuleScope:
isEditMode && initialAlertIds.length === 0 ? 'all' : 'specific',
alertRules: getAlertOptionsFromIds(initialAlertIds, alertOptions),
startTime: startTime ? dayjs(startTime).tz(schedule.timezone) : null,
endTime: endTime ? dayjs(endTime).tz(schedule.timezone) : null,
recurrence: {
@@ -287,6 +301,7 @@ export function PlannedDowntimeForm(
useEffect(() => {
setSelectedTags(formattedInitialValues.alertRules);
setAlertRuleScope(formattedInitialValues.alertRuleScope);
form.setFieldsValue({ ...formattedInitialValues });
}, [form, formattedInitialValues, initialValues]);
@@ -349,6 +364,7 @@ export function PlannedDowntimeForm(
onFinish={onFinish}
onValuesChange={(): void => {
setRecurrenceType(form.getFieldValue('recurrence')?.repeatType as string);
setAlertRuleScope(form.getFieldValue('alertRuleScope') as AlertRuleScope);
handleFormData(form.getFieldsValue());
}}
autoComplete="off"
@@ -448,49 +464,76 @@ export function PlannedDowntimeForm(
<div className="scheduleTimeInfoText">{endTimeText}</div>
)}
<div>
<div className="alert-rule-form">
<Typography style={{ marginBottom: 8 }}>Silence Alerts</Typography>
<Typography style={{ marginBottom: 8 }} className="alert-rule-info">
(Leave empty to silence all alerts)
</Typography>
</div>
<Form.Item noStyle shouldUpdate>
<AlertRuleTags
closable
selectedTags={selectedTags}
handleClose={handleClose}
/>
</Form.Item>
<Form.Item name={alertRuleFormName}>
<Select
placeholder="Search for alerts rules or groups..."
mode="multiple"
status={isError ? 'error' : undefined}
loading={isLoading}
tagRender={noTagRenderer}
onChange={handleAlertRulesChange}
showSearch
options={alertOptions}
filterOption={(input, option): boolean =>
(option?.label as string)?.toLowerCase()?.includes(input.toLowerCase())
}
notFoundContent={
isLoading ? (
<span>
<Spin size="small" /> Loading...
</span>
) : (
<span>No alert available.</span>
)
}
>
{alertOptions?.map((option) => (
<Select.Option key={option.value} value={option.value}>
{option.label}
</Select.Option>
))}
</Select>
<Typography style={{ marginBottom: 8 }}>Silence Alerts</Typography>
<Form.Item
name="alertRuleScope"
initialValue="specific"
className="alert-rule-scope"
>
<Radio.Group>
<Radio value="all">All alert rules</Radio>
<Radio value="specific">Specific alert rules</Radio>
</Radio.Group>
</Form.Item>
{alertRuleScope === 'specific' && (
<>
<Form.Item noStyle shouldUpdate>
<AlertRuleTags
closable
selectedTags={selectedTags}
handleClose={handleClose}
/>
</Form.Item>
<Form.Item
name={alertRuleFormName}
rules={[
{
validator: async (
_rule,
value: DefaultOptionType[] | undefined,
): Promise<void> => {
if (!value || value.length === 0) {
throw new Error(
'Select at least one alert rule, or choose "All alert rules" to silence everything.',
);
}
},
},
]}
>
<Select
placeholder="Search for alert rules or groups..."
mode="multiple"
status={isError ? 'error' : undefined}
loading={isLoading}
tagRender={noTagRenderer}
onChange={handleAlertRulesChange}
showSearch
options={alertOptions}
filterOption={(input, option): boolean =>
(option?.label as string)
?.toLowerCase()
?.includes(input.toLowerCase())
}
notFoundContent={
isLoading ? (
<span>
<Spin size="small" /> Loading...
</span>
) : (
<span>No alert available.</span>
)
}
>
{alertOptions?.map((option) => (
<Select.Option key={option.value} value={option.value}>
{option.label}
</Select.Option>
))}
</Select>
</Form.Item>
</>
)}
</div>
<Form.Item
label={

View File

@@ -204,7 +204,7 @@ export function CollapseListContent({
selectedTags={alertOptions}
/>
) : (
'-'
<Tag className="all-alerts-tag">All alert rules</Tag>
),
)}
</Flex>

View File

@@ -0,0 +1,296 @@
import { renderHook } from '@testing-library/react';
import ROUTES from 'constants/routes';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { getViewQuery } from '../drilldownUtils';
import { AggregateData } from '../useAggregateDrilldown';
import useBaseDrilldownNavigate, {
buildDrilldownUrl,
getRoute,
} from '../useBaseDrilldownNavigate';
const mockSafeNavigate = jest.fn();
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): { safeNavigate: typeof mockSafeNavigate } => ({
safeNavigate: mockSafeNavigate,
}),
}));
jest.mock('../drilldownUtils', () => ({
...jest.requireActual('../drilldownUtils'),
getViewQuery: jest.fn(),
}));
const mockGetViewQuery = getViewQuery as jest.Mock;
// ─── Fixtures ────────────────────────────────────────────────────────────────
const MOCK_QUERY: Query = {
id: 'q1',
queryType: 'builder' as any,
builder: {
queryData: [
{
queryName: 'A',
dataSource: 'metrics' as any,
groupBy: [],
expression: '',
disabled: false,
functions: [],
legend: '',
having: [],
limit: null,
stepInterval: undefined,
orderBy: [],
},
],
queryFormulas: [],
queryTraceOperator: [],
},
promql: [],
clickhouse_sql: [],
};
const MOCK_VIEW_QUERY: Query = {
...MOCK_QUERY,
builder: {
...MOCK_QUERY.builder,
queryData: [
{
...MOCK_QUERY.builder.queryData[0],
filters: { items: [], op: 'AND' },
},
],
},
};
const MOCK_AGGREGATE_DATA: AggregateData = {
queryName: 'A',
filters: [{ filterKey: 'service_name', filterValue: 'auth', operator: '=' }],
timeRange: { startTime: 1000000, endTime: 2000000 },
};
// ─── getRoute ─────────────────────────────────────────────────────────────────
describe('getRoute', () => {
it.each([
['view_logs', ROUTES.LOGS_EXPLORER],
['view_metrics', ROUTES.METRICS_EXPLORER],
['view_traces', ROUTES.TRACES_EXPLORER],
])('maps %s to the correct explorer route', (key, expected) => {
expect(getRoute(key)).toBe(expected);
});
it('returns empty string for an unknown key', () => {
expect(getRoute('view_dashboard')).toBe('');
});
});
// ─── buildDrilldownUrl ────────────────────────────────────────────────────────
describe('buildDrilldownUrl', () => {
beforeEach(() => {
mockGetViewQuery.mockReturnValue(MOCK_VIEW_QUERY);
});
afterEach(() => {
jest.clearAllMocks();
});
it('returns null for an unknown drilldown key', () => {
const url = buildDrilldownUrl(
MOCK_QUERY,
MOCK_AGGREGATE_DATA,
'view_dashboard',
);
expect(url).toBeNull();
});
it('returns null when getViewQuery returns null', () => {
mockGetViewQuery.mockReturnValue(null);
const url = buildDrilldownUrl(MOCK_QUERY, MOCK_AGGREGATE_DATA, 'view_logs');
expect(url).toBeNull();
});
it('returns a URL starting with the logs explorer route for view_logs', () => {
const url = buildDrilldownUrl(MOCK_QUERY, MOCK_AGGREGATE_DATA, 'view_logs');
expect(url).not.toBeNull();
expect(url).toContain(ROUTES.LOGS_EXPLORER);
});
it('returns a URL starting with the traces explorer route for view_traces', () => {
const url = buildDrilldownUrl(MOCK_QUERY, MOCK_AGGREGATE_DATA, 'view_traces');
expect(url).toContain(ROUTES.TRACES_EXPLORER);
});
it('includes compositeQuery param in the URL', () => {
const url = buildDrilldownUrl(MOCK_QUERY, MOCK_AGGREGATE_DATA, 'view_logs');
expect(url).toContain('compositeQuery=');
});
it('includes startTime and endTime when aggregateData has a timeRange', () => {
const url = buildDrilldownUrl(MOCK_QUERY, MOCK_AGGREGATE_DATA, 'view_logs');
expect(url).toContain('startTime=1000000');
expect(url).toContain('endTime=2000000');
});
it('omits startTime and endTime when aggregateData has no timeRange', () => {
const { timeRange: _, ...withoutTimeRange } = MOCK_AGGREGATE_DATA;
const url = buildDrilldownUrl(MOCK_QUERY, withoutTimeRange, 'view_logs');
expect(url).not.toContain('startTime=');
expect(url).not.toContain('endTime=');
});
it('includes summaryFilters param for view_metrics', () => {
const url = buildDrilldownUrl(
MOCK_QUERY,
MOCK_AGGREGATE_DATA,
'view_metrics',
);
expect(url).toContain(ROUTES.METRICS_EXPLORER);
expect(url).toContain('summaryFilters=');
});
it('does not include summaryFilters param for non-metrics routes', () => {
const url = buildDrilldownUrl(MOCK_QUERY, MOCK_AGGREGATE_DATA, 'view_logs');
expect(url).not.toContain('summaryFilters=');
});
it('handles null aggregateData by passing empty filters and empty queryName', () => {
const url = buildDrilldownUrl(MOCK_QUERY, null, 'view_logs');
expect(url).not.toBeNull();
expect(mockGetViewQuery).toHaveBeenCalledWith(
MOCK_QUERY,
[],
'view_logs',
'',
);
});
it('passes aggregateData filters and queryName to getViewQuery', () => {
buildDrilldownUrl(MOCK_QUERY, MOCK_AGGREGATE_DATA, 'view_logs');
expect(mockGetViewQuery).toHaveBeenCalledWith(
MOCK_QUERY,
MOCK_AGGREGATE_DATA.filters,
'view_logs',
MOCK_AGGREGATE_DATA.queryName,
);
});
});
// ─── useBaseDrilldownNavigate ─────────────────────────────────────────────────
describe('useBaseDrilldownNavigate', () => {
beforeEach(() => {
mockGetViewQuery.mockReturnValue(MOCK_VIEW_QUERY);
});
afterEach(() => {
jest.clearAllMocks();
});
it('calls safeNavigate with the built URL on a valid key', () => {
const { result } = renderHook(() =>
useBaseDrilldownNavigate({
resolvedQuery: MOCK_QUERY,
aggregateData: MOCK_AGGREGATE_DATA,
}),
);
result.current('view_logs');
expect(mockSafeNavigate).toHaveBeenCalledTimes(1);
const [url] = mockSafeNavigate.mock.calls[0];
expect(url).toContain(ROUTES.LOGS_EXPLORER);
expect(url).toContain('compositeQuery=');
});
it('opens the explorer in a new tab', () => {
const { result } = renderHook(() =>
useBaseDrilldownNavigate({
resolvedQuery: MOCK_QUERY,
aggregateData: MOCK_AGGREGATE_DATA,
}),
);
result.current('view_traces');
expect(mockSafeNavigate).toHaveBeenCalledWith(expect.any(String), {
newTab: true,
});
});
it('calls callback after successful navigation', () => {
const callback = jest.fn();
const { result } = renderHook(() =>
useBaseDrilldownNavigate({
resolvedQuery: MOCK_QUERY,
aggregateData: MOCK_AGGREGATE_DATA,
callback,
}),
);
result.current('view_logs');
expect(callback).toHaveBeenCalledTimes(1);
});
it('does not call safeNavigate for an unknown key', () => {
const { result } = renderHook(() =>
useBaseDrilldownNavigate({
resolvedQuery: MOCK_QUERY,
aggregateData: MOCK_AGGREGATE_DATA,
}),
);
result.current('view_dashboard');
expect(mockSafeNavigate).not.toHaveBeenCalled();
});
it('still calls callback when the key is unknown', () => {
const callback = jest.fn();
const { result } = renderHook(() =>
useBaseDrilldownNavigate({
resolvedQuery: MOCK_QUERY,
aggregateData: MOCK_AGGREGATE_DATA,
callback,
}),
);
result.current('view_dashboard');
expect(callback).toHaveBeenCalledTimes(1);
expect(mockSafeNavigate).not.toHaveBeenCalled();
});
it('still calls callback when getViewQuery returns null', () => {
mockGetViewQuery.mockReturnValue(null);
const callback = jest.fn();
const { result } = renderHook(() =>
useBaseDrilldownNavigate({
resolvedQuery: MOCK_QUERY,
aggregateData: MOCK_AGGREGATE_DATA,
callback,
}),
);
result.current('view_logs');
expect(callback).toHaveBeenCalledTimes(1);
expect(mockSafeNavigate).not.toHaveBeenCalled();
});
it('handles null aggregateData without throwing', () => {
const { result } = renderHook(() =>
useBaseDrilldownNavigate({
resolvedQuery: MOCK_QUERY,
aggregateData: null,
}),
);
expect(() => result.current('view_logs')).not.toThrow();
expect(mockSafeNavigate).toHaveBeenCalledTimes(1);
});
});

View File

@@ -168,7 +168,7 @@ export const getAggregateColumnHeader = (
};
};
const getFiltersFromMetric = (metric: any): FilterData[] =>
export const getFiltersFromMetric = (metric: any): FilterData[] =>
Object.keys(metric).map((key) => ({
filterKey: key,
filterValue: metric[key],

View File

@@ -2,14 +2,10 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { Link, Loader } from '@signozhq/icons';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import useUpdatedQuery from 'container/GridCardLayout/useResolveQuery';
import { processContextLinks } from 'container/NewWidget/RightContainer/ContextLinks/utils';
import useContextVariables from 'hooks/dashboard/useContextVariables';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import createQueryParams from 'lib/createQueryParams';
import ContextMenu from 'periscope/components/ContextMenu';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import { ContextLinksData } from 'types/api/dashboard/getAll';
@@ -18,9 +14,10 @@ import { openInNewTab } from 'utils/navigation';
import { ContextMenuItem } from './contextConfig';
import { getDataLinks } from './dataLinksUtils';
import { getAggregateColumnHeader, getViewQuery } from './drilldownUtils';
import { getAggregateColumnHeader } from './drilldownUtils';
import { getBaseContextConfig } from './menuOptions';
import { AggregateData } from './useAggregateDrilldown';
import useBaseDrilldownNavigate from './useBaseDrilldownNavigate';
interface UseBaseAggregateOptionsProps {
query: Query;
@@ -38,19 +35,6 @@ interface BaseAggregateOptionsConfig {
items?: ContextMenuItem;
}
const getRoute = (key: string): string => {
switch (key) {
case 'view_logs':
return ROUTES.LOGS_EXPLORER;
case 'view_metrics':
return ROUTES.METRICS_EXPLORER;
case 'view_traces':
return ROUTES.TRACES_EXPLORER;
default:
return '';
}
};
const useBaseAggregateOptions = ({
query,
onClose,
@@ -86,8 +70,6 @@ const useBaseAggregateOptions = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query, aggregateData, panelType]);
const { safeNavigate } = useSafeNavigate();
// Use the new useContextVariables hook
const { processedVariables } = useContextVariables({
maxValues: 2,
@@ -121,50 +103,16 @@ const useBaseAggregateOptions = ({
{label}
</ContextMenu.Item>
));
} catch (error) {
} catch {
return [];
}
}, [contextLinks, processedVariables, onClose, aggregateData, query]);
const handleBaseDrilldown = useCallback(
(key: string): void => {
const route = getRoute(key);
const timeRange = aggregateData?.timeRange;
const filtersToAdd = aggregateData?.filters || [];
const viewQuery = getViewQuery(
resolvedQuery,
filtersToAdd,
key,
aggregateData?.queryName || '',
);
let queryParams = {
[QueryParams.compositeQuery]: encodeURIComponent(JSON.stringify(viewQuery)),
...(timeRange && {
[QueryParams.startTime]: timeRange?.startTime.toString(),
[QueryParams.endTime]: timeRange?.endTime.toString(),
}),
} as Record<string, string>;
if (route === ROUTES.METRICS_EXPLORER) {
queryParams = {
...queryParams,
[QueryParams.summaryFilters]: JSON.stringify(
viewQuery?.builder.queryData[0].filters,
),
};
}
if (route) {
safeNavigate(`${route}?${createQueryParams(queryParams)}`, {
newTab: true,
});
}
onClose();
},
[resolvedQuery, safeNavigate, onClose, aggregateData],
);
const handleBaseDrilldown = useBaseDrilldownNavigate({
resolvedQuery,
aggregateData,
callback: onClose,
});
const { pathname } = useLocation();

View File

@@ -0,0 +1,117 @@
import { useCallback } from 'react';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import createQueryParams from 'lib/createQueryParams';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { getViewQuery } from './drilldownUtils';
import { AggregateData } from './useAggregateDrilldown';
type DrilldownKey = 'view_logs' | 'view_metrics' | 'view_traces';
const DRILLDOWN_ROUTE_MAP: Record<DrilldownKey, string> = {
view_logs: ROUTES.LOGS_EXPLORER,
view_metrics: ROUTES.METRICS_EXPLORER,
view_traces: ROUTES.TRACES_EXPLORER,
};
const getRoute = (key: string): string =>
DRILLDOWN_ROUTE_MAP[key as DrilldownKey] ?? '';
interface UseBaseDrilldownNavigateProps {
resolvedQuery: Query;
aggregateData: AggregateData | null;
callback?: () => void;
}
const useBaseDrilldownNavigate = ({
resolvedQuery,
aggregateData,
callback,
}: UseBaseDrilldownNavigateProps): ((key: string) => void) => {
const { safeNavigate } = useSafeNavigate();
return useCallback(
(key: string): void => {
const route = getRoute(key);
const viewQuery = getViewQuery(
resolvedQuery,
aggregateData?.filters ?? [],
key,
aggregateData?.queryName ?? '',
);
if (!viewQuery || !route) {
callback?.();
return;
}
const timeRange = aggregateData?.timeRange;
let queryParams: Record<string, string> = {
[QueryParams.compositeQuery]: encodeURIComponent(JSON.stringify(viewQuery)),
...(timeRange && {
[QueryParams.startTime]: timeRange.startTime.toString(),
[QueryParams.endTime]: timeRange.endTime.toString(),
}),
};
if (route === ROUTES.METRICS_EXPLORER) {
queryParams = {
...queryParams,
[QueryParams.summaryFilters]: JSON.stringify(
viewQuery.builder.queryData[0].filters,
),
};
}
safeNavigate(`${route}?${createQueryParams(queryParams)}`, {
newTab: true,
});
callback?.();
},
[resolvedQuery, safeNavigate, callback, aggregateData],
);
};
export function buildDrilldownUrl(
resolvedQuery: Query,
aggregateData: AggregateData | null,
key: string,
): string | null {
const route = getRoute(key);
const viewQuery = getViewQuery(
resolvedQuery,
aggregateData?.filters ?? [],
key,
aggregateData?.queryName ?? '',
);
if (!viewQuery || !route) {
return null;
}
const timeRange = aggregateData?.timeRange;
let queryParams: Record<string, string> = {
[QueryParams.compositeQuery]: encodeURIComponent(JSON.stringify(viewQuery)),
...(timeRange && {
[QueryParams.startTime]: timeRange.startTime.toString(),
[QueryParams.endTime]: timeRange.endTime.toString(),
}),
};
if (route === ROUTES.METRICS_EXPLORER) {
queryParams = {
...queryParams,
[QueryParams.summaryFilters]: JSON.stringify(
viewQuery.builder.queryData[0].filters,
),
};
}
return `${route}?${createQueryParams(queryParams)}`;
}
export { getRoute };
export default useBaseDrilldownNavigate;

View File

@@ -0,0 +1,29 @@
import { syncCursorRegistry } from '../syncCursorRegistry';
describe('syncCursorRegistry', () => {
describe('metadata', () => {
it('returns undefined for unknown key', () => {
expect(syncCursorRegistry.getMetadata('unknown-meta')).toBeUndefined();
});
it('stores and retrieves metadata by syncKey', () => {
const metadata = { yAxisUnit: 'ms', groupBy: [] };
syncCursorRegistry.setMetadata('meta-key', metadata);
expect(syncCursorRegistry.getMetadata('meta-key')).toBe(metadata);
});
});
describe('activeSeriesMetric', () => {
it('returns null (not undefined) for unknown key', () => {
expect(
syncCursorRegistry.getActiveSeriesMetric('unknown-metric'),
).toBeNull();
});
it('stores and retrieves metric by syncKey', () => {
const metric = { host: 'server1', __name__: 'cpu' };
syncCursorRegistry.setActiveSeriesMetric('metric-key', metric);
expect(syncCursorRegistry.getActiveSeriesMetric('metric-key')).toBe(metric);
});
});
});

View File

@@ -0,0 +1,627 @@
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import uPlot from 'uplot';
import { syncCursorRegistry } from '../syncCursorRegistry';
import { createSyncDisplayHook } from '../syncDisplayHook';
import {
SyncTooltipFilterMode,
type TooltipControllerState,
type TooltipSyncMetadata,
} from '../types';
jest.mock('../syncCursorRegistry', () => ({
syncCursorRegistry: {
setMetadata: jest.fn(),
getMetadata: jest.fn(),
setActiveSeriesMetric: jest.fn(),
getActiveSeriesMetric: jest.fn(),
},
}));
const mockRegistry = syncCursorRegistry as {
setMetadata: jest.Mock;
getMetadata: jest.Mock;
setActiveSeriesMetric: jest.Mock;
getActiveSeriesMetric: jest.Mock;
};
const SYNC_KEY = 'test-sync-key';
// Builds a single-query groupByPerQuery from a list of dimension keys.
const makeGroupByPerQuery = (
...keys: string[]
): Record<string, BaseAutocompleteData[]> => ({
A: keys.map((key) => ({ key, type: 'tag' as const })),
});
function makeUPlotRoot(includeCrosshair = true): HTMLElement {
const root = document.createElement('div');
if (includeCrosshair) {
const el = document.createElement('div');
el.className = 'u-cursor-y';
root.append(el);
}
return root;
}
type FakeSeries = { metric?: Record<string, string>; show?: boolean };
function makeFakeUPlot(opts: {
cursorEvent?: MouseEvent | null;
cursorLeft?: number;
series?: FakeSeries[];
includeCrosshair?: boolean;
}): uPlot {
return {
root: makeUPlotRoot(opts.includeCrosshair ?? true),
cursor: {
event: opts.cursorEvent !== undefined ? opts.cursorEvent : null,
left: opts.cursorLeft ?? 50,
},
series: opts.series ?? [
{},
{ metric: { host: 'server1' } },
{ metric: { host: 'server2' } },
],
setSeries: jest.fn(),
} as unknown as uPlot;
}
function makeController(
focusedSeriesIndex: number | null = null,
): TooltipControllerState {
return {
focusedSeriesIndex,
syncedSeriesIndexes: null,
} as TooltipControllerState;
}
// Convenience cast used throughout assertions.
function mockSetSeries(u: uPlot): jest.Mock {
return (u as unknown as { setSeries: jest.Mock }).setSeries;
}
function getCrosshair(u: uPlot): HTMLElement {
const el = u.root.querySelector<HTMLElement>('.u-cursor-y');
if (!el) {
throw new Error('crosshair element missing');
}
return el;
}
// ─────────────────────────────────────────────────────────────────────────────
describe('createSyncDisplayHook', () => {
beforeEach(() => {
jest.clearAllMocks();
});
// ── guard ────────────────────────────────────────────────────────────────
describe('no crosshair element', () => {
it('returns early without calling registry when .u-cursor-y absent', () => {
const hook = createSyncDisplayHook(SYNC_KEY, undefined, makeController());
const u = makeFakeUPlot({ includeCrosshair: false });
hook(u);
expect(mockRegistry.setMetadata).not.toHaveBeenCalled();
expect(mockRegistry.getMetadata).not.toHaveBeenCalled();
expect(mockSetSeries(u)).not.toHaveBeenCalled();
});
});
// ── source panel ─────────────────────────────────────────────────────────
describe('source behavior (cursor.event != null)', () => {
it('writes syncMetadata to registry', () => {
const syncMetadata: TooltipSyncMetadata = { yAxisUnit: 'ms' };
const hook = createSyncDisplayHook(SYNC_KEY, syncMetadata, makeController());
const u = makeFakeUPlot({ cursorEvent: new MouseEvent('mousemove') });
hook(u);
expect(mockRegistry.setMetadata).toHaveBeenCalledWith(
SYNC_KEY,
syncMetadata,
);
});
it('writes focused series metric when focusedSeriesIndex is set', () => {
const series: FakeSeries[] = [
{},
{ metric: { host: 'server1' } },
{ metric: { host: 'server2' } },
];
const hook = createSyncDisplayHook(SYNC_KEY, undefined, makeController(1));
const u = makeFakeUPlot({
cursorEvent: new MouseEvent('mousemove'),
series,
});
hook(u);
expect(mockRegistry.setActiveSeriesMetric).toHaveBeenCalledWith(SYNC_KEY, {
host: 'server1',
});
});
it('writes null metric when focusedSeriesIndex is null', () => {
const hook = createSyncDisplayHook(
SYNC_KEY,
undefined,
makeController(null),
);
const u = makeFakeUPlot({ cursorEvent: new MouseEvent('mousemove') });
hook(u);
expect(mockRegistry.setActiveSeriesMetric).toHaveBeenCalledWith(
SYNC_KEY,
null,
);
});
it('clears controller.syncedSeriesIndexes', () => {
const controller = makeController();
controller.syncedSeriesIndexes = [1, 2];
const hook = createSyncDisplayHook(SYNC_KEY, undefined, controller);
const u = makeFakeUPlot({ cursorEvent: new MouseEvent('mousemove') });
hook(u);
expect(controller.syncedSeriesIndexes).toBeNull();
});
it('shows crosshair and does not read from registry', () => {
const hook = createSyncDisplayHook(SYNC_KEY, undefined, makeController());
const u = makeFakeUPlot({ cursorEvent: new MouseEvent('mousemove') });
hook(u);
expect(getCrosshair(u).style.display).toBe('');
expect(mockRegistry.getMetadata).not.toHaveBeenCalled();
});
});
// ── receiver panel ───────────────────────────────────────────────────────
describe('receiver behavior (cursor.event is null)', () => {
describe('crosshair visibility', () => {
it('shows crosshair when yAxisUnit matches source', () => {
mockRegistry.getMetadata.mockReturnValue({ yAxisUnit: 'ms' });
mockRegistry.getActiveSeriesMetric.mockReturnValue(null);
const hook = createSyncDisplayHook(
SYNC_KEY,
{ yAxisUnit: 'ms' },
makeController(),
);
const u = makeFakeUPlot({ cursorEvent: null });
hook(u);
expect(getCrosshair(u).style.display).toBe('');
});
it('hides crosshair when yAxisUnit differs from source', () => {
mockRegistry.getMetadata.mockReturnValue({ yAxisUnit: 'bytes' });
mockRegistry.getActiveSeriesMetric.mockReturnValue(null);
const hook = createSyncDisplayHook(
SYNC_KEY,
{ yAxisUnit: 'ms' },
makeController(),
);
const u = makeFakeUPlot({ cursorEvent: null });
hook(u);
expect(getCrosshair(u).style.display).toBe('none');
});
});
// ── exact groupBy match ───────────────────────────────────────────────
describe('exact groupBy match', () => {
const groupByPerQuery = makeGroupByPerQuery('host');
const series: FakeSeries[] = [
{},
{ metric: { host: 'server1' } },
{ metric: { host: 'server2' } },
];
it('focuses the matching series and records it on the controller', () => {
mockRegistry.getMetadata.mockReturnValue({
yAxisUnit: 'ms',
groupByPerQuery,
});
mockRegistry.getActiveSeriesMetric.mockReturnValue({ host: 'server2' });
const controller = makeController();
const hook = createSyncDisplayHook(
SYNC_KEY,
{ yAxisUnit: 'ms', groupByPerQuery },
controller,
);
const u = makeFakeUPlot({ cursorEvent: null, cursorLeft: 50, series });
hook(u);
expect(mockSetSeries(u)).toHaveBeenCalledWith(2, { focus: true });
expect(controller.syncedSeriesIndexes).toStrictEqual([2]);
});
it('unfocuses all and emits empty matches (Filtered) when active metric is null', () => {
mockRegistry.getMetadata.mockReturnValue({
yAxisUnit: 'ms',
groupByPerQuery,
});
mockRegistry.getActiveSeriesMetric.mockReturnValue(null);
const controller = makeController();
const hook = createSyncDisplayHook(
SYNC_KEY,
{ yAxisUnit: 'ms', groupByPerQuery },
controller,
);
const u = makeFakeUPlot({ cursorEvent: null, cursorLeft: 50, series });
hook(u);
expect(mockSetSeries(u)).toHaveBeenCalledWith(null, { focus: false });
expect(controller.syncedSeriesIndexes).toStrictEqual([]);
});
it('unfocuses all when metric matches no series', () => {
mockRegistry.getMetadata.mockReturnValue({
yAxisUnit: 'ms',
groupByPerQuery,
});
mockRegistry.getActiveSeriesMetric.mockReturnValue({
host: 'unknown-server',
});
const controller = makeController();
const hook = createSyncDisplayHook(
SYNC_KEY,
{ yAxisUnit: 'ms', groupByPerQuery },
controller,
);
const u = makeFakeUPlot({ cursorEvent: null, cursorLeft: 50, series });
hook(u);
expect(mockSetSeries(u)).toHaveBeenCalledWith(null, { focus: false });
expect(controller.syncedSeriesIndexes).toStrictEqual([]);
});
it('clears syncedSeriesIndexes when cursor is off-plot (left < 0)', () => {
mockRegistry.getMetadata.mockReturnValue({
yAxisUnit: 'ms',
groupByPerQuery,
});
mockRegistry.getActiveSeriesMetric.mockReturnValue({ host: 'server1' });
const controller = makeController();
const hook = createSyncDisplayHook(
SYNC_KEY,
{ yAxisUnit: 'ms', groupByPerQuery },
controller,
);
const u = makeFakeUPlot({ cursorEvent: null, cursorLeft: -1, series });
hook(u);
expect(mockSetSeries(u)).toHaveBeenCalledWith(null, { focus: false });
expect(controller.syncedSeriesIndexes).toBeNull();
expect(mockRegistry.getActiveSeriesMetric).not.toHaveBeenCalled();
});
it('never focuses series at index 0 (x-axis)', () => {
const sameMetric = { host: 'server1' };
mockRegistry.getMetadata.mockReturnValue({
yAxisUnit: 'ms',
groupByPerQuery,
});
mockRegistry.getActiveSeriesMetric.mockReturnValue(sameMetric);
const controller = makeController();
const hook = createSyncDisplayHook(
SYNC_KEY,
{ yAxisUnit: 'ms', groupByPerQuery },
controller,
);
const u = makeFakeUPlot({
cursorEvent: null,
cursorLeft: 50,
// Index 0 has the same metric — it must always be skipped.
series: [{ metric: sameMetric }, { metric: { host: 'other' } }],
});
hook(u);
expect(mockSetSeries(u)).toHaveBeenCalledWith(null, { focus: false });
expect(controller.syncedSeriesIndexes).toStrictEqual([]);
});
it('skips hidden series (show === false)', () => {
mockRegistry.getMetadata.mockReturnValue({
yAxisUnit: 'ms',
groupByPerQuery,
});
mockRegistry.getActiveSeriesMetric.mockReturnValue({ host: 'server1' });
const controller = makeController();
const hook = createSyncDisplayHook(
SYNC_KEY,
{ yAxisUnit: 'ms', groupByPerQuery },
controller,
);
const u = makeFakeUPlot({
cursorEvent: null,
cursorLeft: 50,
series: [
{},
{ metric: { host: 'server1' }, show: false },
{ metric: { host: 'server1' } },
],
});
hook(u);
expect(mockSetSeries(u)).toHaveBeenCalledWith(2, { focus: true });
expect(controller.syncedSeriesIndexes).toStrictEqual([2]);
});
});
// ── partial groupBy overlap ───────────────────────────────────────────
describe('partial groupBy overlap', () => {
it('subset — records every receiver series matching on the common key', () => {
// Source groupBy=[host], receiver groupBy=[host, service].
// Hook focuses the first match; the rest are surfaced via controller.syncedSeriesIndexes.
const sourceGroupBy = makeGroupByPerQuery('host');
const receiverGroupBy = makeGroupByPerQuery('host', 'service');
const series: FakeSeries[] = [
{},
{ metric: { host: 'server1', service: 'api' } },
{ metric: { host: 'server1', service: 'frontend' } },
{ metric: { host: 'server2', service: 'api' } },
];
mockRegistry.getMetadata.mockReturnValue({
yAxisUnit: 'ms',
groupByPerQuery: sourceGroupBy,
});
mockRegistry.getActiveSeriesMetric.mockReturnValue({ host: 'server1' });
const controller = makeController();
const hook = createSyncDisplayHook(
SYNC_KEY,
{ yAxisUnit: 'ms', groupByPerQuery: receiverGroupBy },
controller,
);
const u = makeFakeUPlot({ cursorEvent: null, cursorLeft: 50, series });
hook(u);
expect(mockSetSeries(u)).toHaveBeenCalledWith(1, { focus: true });
expect(controller.syncedSeriesIndexes).toStrictEqual([1, 2]);
});
it('superset — records the one receiver series matching on the common key', () => {
// Source groupBy=[host, service], receiver groupBy=[host].
const sourceGroupBy = makeGroupByPerQuery('host', 'service');
const receiverGroupBy = makeGroupByPerQuery('host');
const series: FakeSeries[] = [
{},
{ metric: { host: 'server1' } },
{ metric: { host: 'server2' } },
];
mockRegistry.getMetadata.mockReturnValue({
yAxisUnit: 'ms',
groupByPerQuery: sourceGroupBy,
});
mockRegistry.getActiveSeriesMetric.mockReturnValue({
host: 'server1',
service: 'api',
});
const controller = makeController();
const hook = createSyncDisplayHook(
SYNC_KEY,
{ yAxisUnit: 'ms', groupByPerQuery: receiverGroupBy },
controller,
);
const u = makeFakeUPlot({ cursorEvent: null, cursorLeft: 50, series });
hook(u);
expect(mockSetSeries(u)).toHaveBeenCalledWith(1, { focus: true });
expect(controller.syncedSeriesIndexes).toStrictEqual([1]);
});
it('partial — matches on the intersecting key only', () => {
// Source groupBy=[host, service], receiver groupBy=[service, region].
// Common key is [service]. Both receiver series with service=api match.
const sourceGroupBy = makeGroupByPerQuery('host', 'service');
const receiverGroupBy = makeGroupByPerQuery('service', 'region');
const series: FakeSeries[] = [
{},
{ metric: { service: 'api', region: 'us-east' } },
{ metric: { service: 'api', region: 'eu-west' } },
{ metric: { service: 'frontend', region: 'us-east' } },
];
mockRegistry.getMetadata.mockReturnValue({
yAxisUnit: 'ms',
groupByPerQuery: sourceGroupBy,
});
mockRegistry.getActiveSeriesMetric.mockReturnValue({
host: 'server1',
service: 'api',
});
const controller = makeController();
const hook = createSyncDisplayHook(
SYNC_KEY,
{ yAxisUnit: 'ms', groupByPerQuery: receiverGroupBy },
controller,
);
const u = makeFakeUPlot({ cursorEvent: null, cursorLeft: 50, series });
hook(u);
expect(mockSetSeries(u)).toHaveBeenCalledWith(1, { focus: true });
expect(controller.syncedSeriesIndexes).toStrictEqual([1, 2]);
});
});
// ── union across queries in groupByPerQuery ───────────────────────────
describe('union across queries', () => {
it("treats the panel's effective groupBy as the union across its queries", () => {
// Source has query A=[host]; receiver has A=[host], B=[service].
// The shared key is `host` — receiver matches on that.
const sourceGroupBy: Record<string, BaseAutocompleteData[]> = {
A: [{ key: 'host', type: 'tag' }],
};
const receiverGroupBy: Record<string, BaseAutocompleteData[]> = {
A: [{ key: 'host', type: 'tag' }],
B: [{ key: 'service', type: 'tag' }],
};
mockRegistry.getMetadata.mockReturnValue({
yAxisUnit: 'ms',
groupByPerQuery: sourceGroupBy,
});
mockRegistry.getActiveSeriesMetric.mockReturnValue({ host: 'server1' });
const controller = makeController();
const hook = createSyncDisplayHook(
SYNC_KEY,
{ yAxisUnit: 'ms', groupByPerQuery: receiverGroupBy },
controller,
);
const u = makeFakeUPlot({
cursorEvent: null,
cursorLeft: 50,
series: [
{},
{ metric: { host: 'server1' } },
{ metric: { host: 'server2' } },
],
});
hook(u);
expect(controller.syncedSeriesIndexes).toStrictEqual([1]);
});
});
// ── no overlap (Filtered mode default) ────────────────────────────────
describe('no overlap → Filtered mode emits []', () => {
it('emits [] when groupBy keys are completely different', () => {
mockRegistry.getMetadata.mockReturnValue({
yAxisUnit: 'ms',
groupByPerQuery: makeGroupByPerQuery('host'),
});
mockRegistry.getActiveSeriesMetric.mockReturnValue({ host: 'server1' });
const controller = makeController();
const hook = createSyncDisplayHook(
SYNC_KEY,
{ yAxisUnit: 'ms', groupByPerQuery: makeGroupByPerQuery('service') },
controller,
);
const u = makeFakeUPlot({ cursorEvent: null });
hook(u);
expect(controller.syncedSeriesIndexes).toStrictEqual([]);
});
it('emits [] when receiver groupBy is empty', () => {
mockRegistry.getMetadata.mockReturnValue({
yAxisUnit: 'ms',
groupByPerQuery: makeGroupByPerQuery('host'),
});
mockRegistry.getActiveSeriesMetric.mockReturnValue({ host: 'server1' });
const controller = makeController();
const hook = createSyncDisplayHook(
SYNC_KEY,
{ yAxisUnit: 'ms', groupByPerQuery: {} },
controller,
);
const u = makeFakeUPlot({ cursorEvent: null });
hook(u);
expect(controller.syncedSeriesIndexes).toStrictEqual([]);
});
it('emits [] when source groupBy is absent', () => {
mockRegistry.getMetadata.mockReturnValue({ yAxisUnit: 'ms' });
const controller = makeController();
const hook = createSyncDisplayHook(
SYNC_KEY,
{ yAxisUnit: 'ms', groupByPerQuery: makeGroupByPerQuery('host') },
controller,
);
const u = makeFakeUPlot({ cursorEvent: null });
hook(u);
expect(controller.syncedSeriesIndexes).toStrictEqual([]);
});
});
// ── filterMode: All ──────────────────────────────────────────────────
describe('filterMode All', () => {
it('emits null (no filter) when there is no overlap in groupBy', () => {
mockRegistry.getMetadata.mockReturnValue({
yAxisUnit: 'ms',
groupByPerQuery: makeGroupByPerQuery('host'),
});
mockRegistry.getActiveSeriesMetric.mockReturnValue({ host: 'server1' });
const controller = makeController();
const hook = createSyncDisplayHook(
SYNC_KEY,
{
yAxisUnit: 'ms',
groupByPerQuery: makeGroupByPerQuery('service'),
filterMode: SyncTooltipFilterMode.All,
},
controller,
);
const u = makeFakeUPlot({ cursorEvent: null });
hook(u);
expect(controller.syncedSeriesIndexes).toBeNull();
});
it('emits null when metric matches no series', () => {
const groupByPerQuery = makeGroupByPerQuery('host');
mockRegistry.getMetadata.mockReturnValue({
yAxisUnit: 'ms',
groupByPerQuery,
});
mockRegistry.getActiveSeriesMetric.mockReturnValue({ host: 'unknown' });
const controller = makeController();
const hook = createSyncDisplayHook(
SYNC_KEY,
{
yAxisUnit: 'ms',
groupByPerQuery,
filterMode: SyncTooltipFilterMode.All,
},
controller,
);
const u = makeFakeUPlot({ cursorEvent: null, cursorLeft: 50 });
hook(u);
expect(controller.syncedSeriesIndexes).toBeNull();
});
});
// ── caching ───────────────────────────────────────────────────────────
describe('caching optimizations', () => {
it('reuses the crosshair element across multiple invocations', () => {
mockRegistry.getMetadata.mockReturnValue({ yAxisUnit: 'ms' });
mockRegistry.getActiveSeriesMetric.mockReturnValue(null);
const hook = createSyncDisplayHook(
SYNC_KEY,
{ yAxisUnit: 'ms' },
makeController(),
);
const u = makeFakeUPlot({ cursorEvent: null });
const spy = jest.spyOn(u.root, 'querySelector');
hook(u);
hook(u);
hook(u);
// querySelector should only be called once regardless of invocation count.
expect(spy).toHaveBeenCalledTimes(1);
});
it('recomputes common keys when source groupByPerQuery reference changes', () => {
const hostGroupBy = makeGroupByPerQuery('host');
const serviceGroupBy = makeGroupByPerQuery('service');
const series: FakeSeries[] = [
{},
{ metric: { host: 'server1', service: 'api' } },
{ metric: { host: 'server2', service: 'frontend' } },
];
const hook = createSyncDisplayHook(
SYNC_KEY,
{ groupByPerQuery: makeGroupByPerQuery('host', 'service') },
makeController(),
);
const u = makeFakeUPlot({ cursorEvent: null, cursorLeft: 50, series });
// First call: source groups by host → matches series 1.
mockRegistry.getMetadata.mockReturnValue({
groupByPerQuery: hostGroupBy,
});
mockRegistry.getActiveSeriesMetric.mockReturnValue({ host: 'server1' });
hook(u);
expect(mockSetSeries(u)).toHaveBeenCalledWith(1, { focus: true });
jest.clearAllMocks();
// Second call: source now groups by service → matches series 2.
mockRegistry.getMetadata.mockReturnValue({
groupByPerQuery: serviceGroupBy,
});
mockRegistry.getActiveSeriesMetric.mockReturnValue({
service: 'frontend',
});
hook(u);
expect(mockSetSeries(u)).toHaveBeenCalledWith(2, { focus: true });
});
});
});
});

View File

@@ -0,0 +1,77 @@
import { ReactNode } from 'react';
import { Dock, PanelBottom, PanelRight } from '@signozhq/icons';
import { ToggleGroup, ToggleGroupItem } from '@signozhq/ui/toggle-group';
import {
TooltipContent,
TooltipProvider,
TooltipRoot,
TooltipTrigger,
} from '@signozhq/ui/tooltip';
import { SpanDetailVariant } from './constants';
interface DockOption {
value: SpanDetailVariant;
icon: ReactNode;
tooltip: string;
}
const DOCK_OPTIONS: DockOption[] = [
{
value: SpanDetailVariant.DIALOG,
icon: <Dock size={14} />,
tooltip: 'Open as floating panel',
},
{
value: SpanDetailVariant.DOCKED,
icon: <PanelBottom size={14} />,
tooltip: 'Dock at the bottom',
},
{
value: SpanDetailVariant.DOCKED_RIGHT,
icon: <PanelRight size={14} />,
tooltip: 'Dock on the right',
},
];
interface DockModeSwitcherProps {
value: SpanDetailVariant;
onChange: (value: SpanDetailVariant) => void;
tooltipClassName?: string;
}
function DockModeSwitcher({
value,
onChange,
tooltipClassName,
}: DockModeSwitcherProps): JSX.Element {
return (
<TooltipProvider>
<ToggleGroup
type="single"
value={value}
onChange={(v): void => {
if (v) {
onChange(v as SpanDetailVariant);
}
}}
size="sm"
>
{DOCK_OPTIONS.map((option) => (
<TooltipRoot key={option.value}>
<TooltipTrigger asChild>
<span>
<ToggleGroupItem value={option.value}>{option.icon}</ToggleGroupItem>
</span>
</TooltipTrigger>
<TooltipContent className={tooltipClassName}>
{option.tooltip}
</TooltipContent>
</TooltipRoot>
))}
</ToggleGroup>
</TooltipProvider>
);
}
export default DockModeSwitcher;

View File

@@ -3,6 +3,10 @@
display: flex;
flex-direction: column;
overflow: hidden;
:global(.details-header) {
height: 39px;
}
}
.body {

View File

@@ -1,25 +1,16 @@
import { useCallback, useMemo } from 'react';
import { Button } from '@signozhq/ui/button';
import {
TabsContent,
TabsList,
TabsRoot,
TabsTrigger,
} from '@signozhq/ui/tabs';
import {
TooltipRoot,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@signozhq/ui/tooltip';
import {
Bookmark,
CalendarClock,
ChartColumnBig,
Dock,
Link2,
List,
PanelBottom,
ScrollText,
Timer,
} from '@signozhq/icons';
@@ -61,6 +52,7 @@ import {
SpanDetailVariant,
VISIBLE_ACTIONS,
} from './constants';
import DockModeSwitcher from './DockModeSwitcher';
import { useSpanAttributeActions } from './hooks/useSpanAttributeActions';
import { useTracePinnedFields } from './hooks/useTracePinnedFields';
import {
@@ -492,31 +484,14 @@ function SpanDetailsPanel({
];
if (onVariantChange) {
const isDocked = variant === SpanDetailVariant.DOCKED;
actions.push({
key: 'dock-toggle',
key: 'dock-mode',
component: (
<TooltipProvider>
<TooltipRoot>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
color="secondary"
onClick={(): void =>
onVariantChange(
isDocked ? SpanDetailVariant.DIALOG : SpanDetailVariant.DOCKED,
)
}
>
{isDocked ? <Dock size={14} /> : <PanelBottom size={14} />}
</Button>
</TooltipTrigger>
<TooltipContent className={styles.dockToggleTooltip}>
{isDocked ? 'Open as floating panel' : 'Dock at the bottom'}
</TooltipContent>
</TooltipRoot>
</TooltipProvider>
<DockModeSwitcher
value={variant}
onChange={onVariantChange}
tooltipClassName={styles.dockToggleTooltip}
/>
),
});
}
@@ -553,7 +528,10 @@ function SpanDetailsPanel({
</>
);
if (variant === SpanDetailVariant.DOCKED) {
if (
variant === SpanDetailVariant.DOCKED ||
variant === SpanDetailVariant.DOCKED_RIGHT
) {
return <div className={styles.root}>{content}</div>;
}

View File

@@ -22,6 +22,7 @@ export enum SpanDetailVariant {
DRAWER = 'drawer',
DIALOG = 'dialog',
DOCKED = 'docked',
DOCKED_RIGHT = 'right',
}
export const KEY_ATTRIBUTE_KEYS: Record<string, string[]> = {

View File

@@ -4,13 +4,28 @@
flex-direction: column;
}
.layoutRow {
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
}
.content {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
overflow: hidden;
}
.rightDock {
display: flex;
flex-direction: column;
border-left: 1px solid var(--l2-border);
min-width: 0;
}
// Shared Ant Collapse chrome reset for both the flamegraph and waterfall
// collapse panels.
.flameCollapse,

View File

@@ -893,7 +893,7 @@ function Success(props: ISuccessProps): JSX.Element {
/>
{/* Left panel - table with horizontal scroll */}
<ResizableBox
direction="horizontal"
handle="right"
defaultWidth={DEFAULT_SIDEBAR_WIDTH}
minWidth={MIN_SIDEBAR_WIDTH}
maxWidth={MAX_SIDEBAR_WIDTH}

View File

@@ -242,9 +242,13 @@ function TraceDetailsV3(): JSX.Element {
() =>
(getLocalStorageKey(
LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION,
) as SpanDetailVariant) || SpanDetailVariant.DIALOG,
) as SpanDetailVariant) || SpanDetailVariant.DOCKED_RIGHT,
);
const RIGHT_DOCK_MIN = 480;
const RIGHT_DOCK_MAX = 720;
const [rightDockWidth, setRightDockWidth] = useState(RIGHT_DOCK_MIN);
const handleVariantChange = useCallback(
(newVariant: SpanDetailVariant): void => {
setLocalStorageKey(
@@ -291,7 +295,9 @@ function TraceDetailsV3(): JSX.Element {
(!!errorFetchingTraceData || !traceData?.payload?.spans?.length);
const isDocked = spanDetailVariant === SpanDetailVariant.DOCKED;
const isRightDocked = spanDetailVariant === SpanDetailVariant.DOCKED_RIGHT;
const isWaterfallDocked = panelState.isOpen && isDocked;
const showRightDock = panelState.isOpen && isRightDocked;
const waterfallChildren = (
<ResizableBox
@@ -332,94 +338,118 @@ function TraceDetailsV3(): JSX.Element {
<NoData />
) : (
<>
<div className={styles.content}>
<Collapse
// @ts-expect-error motion is passed through to rc-collapse to disable animation
motion={false}
activeKey={activeKeys.filter((k) => k === 'flame')}
onChange={(): void => handleCollapseChange('flame')}
size="small"
className={styles.flameCollapse}
items={[
{
key: 'flame',
label: (
<div className={styles.collapseLabel}>
<span className={styles.collapseTitle}>
Flame Graph
{traceData?.payload?.totalSpansCount &&
traceData.payload.totalSpansCount > FLAMEGRAPH_SPAN_LIMIT && (
<WarningPopover
message="The total span count exceeds the visualization limit. Displaying a sampled subset of spans in flamegraph."
placement="bottomLeft"
/>
)}
</span>
{traceData?.payload?.totalSpansCount ? (
<span className={styles.collapseCount}>
<span className={styles.collapseCountItem}>
<ChartNoAxesGantt size={13} />
Spans: {traceData.payload.totalSpansCount}
</span>
<span
className={cx(styles.collapseCountItem, {
[styles.hasErrors]: traceData.payload.totalErrorSpansCount > 0,
})}
>
<TriangleAlert size={13} />
Errors: {traceData.payload.totalErrorSpansCount ?? 0}
</span>
<div className={styles.layoutRow}>
<div className={styles.content}>
<Collapse
// @ts-expect-error motion is passed through to rc-collapse to disable animation
motion={false}
activeKey={activeKeys.filter((k) => k === 'flame')}
onChange={(): void => handleCollapseChange('flame')}
size="small"
className={styles.flameCollapse}
items={[
{
key: 'flame',
label: (
<div className={styles.collapseLabel}>
<span className={styles.collapseTitle}>
Flame Graph
{traceData?.payload?.totalSpansCount &&
traceData.payload.totalSpansCount > FLAMEGRAPH_SPAN_LIMIT && (
<WarningPopover
message="The total span count exceeds the visualization limit. Displaying a sampled subset of spans in flamegraph."
placement="bottomLeft"
/>
)}
</span>
) : null}
</div>
),
children: (
<ResizableBox defaultHeight={300} minHeight={100} maxHeight={400}>
<TraceFlamegraph
filteredSpanIds={filteredSpanIds}
isFilterActive={isFilterActive}
selectedSpan={selectedSpan}
totalSpansCount={totalSpansCount}
/>
</ResizableBox>
),
},
]}
/>
{traceData?.payload?.totalSpansCount ? (
<span className={styles.collapseCount}>
<span className={styles.collapseCountItem}>
<ChartNoAxesGantt size={13} />
Spans: {traceData.payload.totalSpansCount}
</span>
<span
className={cx(styles.collapseCountItem, {
[styles.hasErrors]: traceData.payload.totalErrorSpansCount > 0,
})}
>
<TriangleAlert size={13} />
Errors: {traceData.payload.totalErrorSpansCount ?? 0}
</span>
</span>
) : null}
</div>
),
children: (
<ResizableBox defaultHeight={300} minHeight={100} maxHeight={400}>
<TraceFlamegraph
filteredSpanIds={filteredSpanIds}
isFilterActive={isFilterActive}
selectedSpan={selectedSpan}
totalSpansCount={totalSpansCount}
/>
</ResizableBox>
),
},
]}
/>
<Collapse
// @ts-expect-error motion is passed through to rc-collapse to disable animation
motion={false}
activeKey={activeKeys.filter((k) => k === 'waterfall')}
onChange={(): void => handleCollapseChange('waterfall')}
size="small"
className={cx(styles.waterfallCollapse, {
[styles.isDocked]: isWaterfallDocked,
})}
items={[
{
key: 'waterfall',
label: 'Waterfall',
children: activeKeys.includes('waterfall') ? waterfallChildren : null,
},
]}
/>
<Collapse
// @ts-expect-error motion is passed through to rc-collapse to disable animation
motion={false}
activeKey={activeKeys.filter((k) => k === 'waterfall')}
onChange={(): void => handleCollapseChange('waterfall')}
size="small"
className={cx(styles.waterfallCollapse, {
[styles.isDocked]: isWaterfallDocked,
})}
items={[
{
key: 'waterfall',
label: 'Waterfall',
children: activeKeys.includes('waterfall')
? waterfallChildren
: null,
},
]}
/>
{panelState.isOpen && isDocked && (
<div className={styles.dockedSpanDetails}>
{panelState.isOpen && isDocked && (
<div className={styles.dockedSpanDetails}>
<SpanDetailsPanel
panelState={panelState}
selectedSpan={selectedSpan}
variant={SpanDetailVariant.DOCKED}
onVariantChange={handleVariantChange}
traceStartTime={traceData?.payload?.startTimestampMillis}
traceEndTime={traceData?.payload?.endTimestampMillis}
/>
</div>
)}
</div>
{showRightDock && (
<ResizableBox
handle="left"
defaultWidth={rightDockWidth}
minWidth={RIGHT_DOCK_MIN}
maxWidth={RIGHT_DOCK_MAX}
onResize={setRightDockWidth}
className={styles.rightDock}
>
<SpanDetailsPanel
panelState={panelState}
selectedSpan={selectedSpan}
variant={SpanDetailVariant.DOCKED}
variant={SpanDetailVariant.DOCKED_RIGHT}
onVariantChange={handleVariantChange}
traceStartTime={traceData?.payload?.startTimestampMillis}
traceEndTime={traceData?.payload?.endTimestampMillis}
/>
</div>
</ResizableBox>
)}
</div>
{panelState.isOpen && !isDocked && (
{panelState.isOpen && spanDetailVariant === SpanDetailVariant.DIALOG && (
<SpanDetailsPanel
panelState={panelState}
selectedSpan={selectedSpan}

View File

@@ -23,20 +23,36 @@
background: var(--primary);
}
&--vertical {
bottom: 0;
&--top,
&--bottom {
left: 0;
right: 0;
height: 1px;
cursor: row-resize;
}
&--horizontal {
right: 0;
&--left,
&--right {
top: 0;
bottom: 0;
width: 1px;
cursor: col-resize;
}
&--top {
top: 0;
}
&--bottom {
bottom: 0;
}
&--left {
left: 0;
}
&--right {
right: 0;
}
}
}

View File

@@ -2,9 +2,15 @@ import { useCallback, useRef, useState } from 'react';
import './ResizableBox.styles.scss';
export type ResizableBoxHandle = 'top' | 'right' | 'bottom' | 'left';
export interface ResizableBoxProps {
children: React.ReactNode;
direction?: 'vertical' | 'horizontal';
// Which edge the resize handle sits on. The edge determines the axis:
// 'top'/'bottom' → vertical resize (height), 'left'/'right' → horizontal
// resize (width). Dragging the handle away from the content grows the box;
// dragging it toward the content shrinks it.
handle?: ResizableBoxHandle;
defaultHeight?: number;
minHeight?: number;
maxHeight?: number;
@@ -18,7 +24,7 @@ export interface ResizableBoxProps {
function ResizableBox({
children,
direction = 'vertical',
handle = 'bottom',
defaultHeight = 200,
minHeight = 50,
maxHeight = Infinity,
@@ -29,7 +35,8 @@ function ResizableBox({
disabled = false,
className,
}: ResizableBoxProps): JSX.Element {
const isHorizontal = direction === 'horizontal';
const isHorizontal = handle === 'left' || handle === 'right';
const isStartHandle = handle === 'top' || handle === 'left';
const [size, setSize] = useState(isHorizontal ? defaultWidth : defaultHeight);
const containerRef = useRef<HTMLDivElement>(null);
@@ -40,10 +47,13 @@ function ResizableBox({
const startSize = size;
const min = isHorizontal ? minWidth : minHeight;
const max = isHorizontal ? maxWidth : maxHeight;
// Start-edge handle: pointer moving away from content (negative delta)
// grows the box, so invert the sign.
const deltaSign = isStartHandle ? -1 : 1;
const onMouseMove = (moveEvent: MouseEvent): void => {
const currentPos = isHorizontal ? moveEvent.clientX : moveEvent.clientY;
const delta = currentPos - startPos;
const delta = (currentPos - startPos) * deltaSign;
const newSize = Math.min(max, Math.max(min, startSize + delta));
setSize(newSize);
onResize?.(newSize);
@@ -61,7 +71,16 @@ function ResizableBox({
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
},
[size, isHorizontal, minWidth, maxWidth, minHeight, maxHeight, onResize],
[
size,
isHorizontal,
isStartHandle,
minWidth,
maxWidth,
minHeight,
maxHeight,
onResize,
],
);
const containerStyle = disabled
@@ -69,7 +88,7 @@ function ResizableBox({
: isHorizontal
? { width: size }
: { height: size };
const handleClass = `resizable-box__handle resizable-box__handle--${direction}`;
const handleClass = `resizable-box__handle resizable-box__handle--${handle}`;
return (
<div

View File

@@ -23,7 +23,13 @@ import (
"sync"
"time"
htmltemplate "html/template"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/templating/markdownrenderer"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
commoncfg "github.com/prometheus/common/config"
"github.com/prometheus/alertmanager/config"
@@ -34,20 +40,39 @@ import (
const (
Integration = "email"
// alertEmailLayoutTemplate is the name of the HTML layout template that
// wraps the rendered alert bodies. It is loaded into the notification
// template (n.tmpl) from the alertmanager templates config and lives at
// templates/alertmanager/email.gotmpl.
alertEmailLayoutTemplate = "email.signoz.html"
)
// Email implements a Notifier for email notifications.
type Email struct {
conf *config.EmailConfig
tmpl *template.Template
logger *slog.Logger
hostname string
conf *config.EmailConfig
tmpl *template.Template
logger *slog.Logger
hostname string
templater alertmanagertypes.Templater
}
// layoutData is the value passed to the email.signoz.html layout
// template. It embeds NotificationTemplateData so templates can reference
// `.Alert.Status`, `.Alert.TotalFiring`, `.Alert.TotalResolved`,
// `.NotificationTemplateData.ExternalURL`, etc. alongside the rendered
// Title and per-alert Bodies.
type layoutData struct {
alertmanagertypes.NotificationTemplateData
Title string
Bodies []htmltemplate.HTML
}
var errNoAuthUsernameConfigured = errors.NewInternalf(errors.CodeInternal, "no auth username configured")
// New returns a new Email notifier.
func New(c *config.EmailConfig, t *template.Template, l *slog.Logger) *Email {
// New returns a new Email notifier. When the email.signoz.html layout is
// not defined in t, custom-body alerts fall back to plain <div>-wrapped HTML.
func New(c *config.EmailConfig, t *template.Template, l *slog.Logger, templater alertmanagertypes.Templater) *Email {
if _, ok := c.Headers["Subject"]; !ok {
c.Headers["Subject"] = config.DefaultEmailSubject
}
@@ -63,7 +88,7 @@ func New(c *config.EmailConfig, t *template.Template, l *slog.Logger) *Email {
if err != nil {
h = "localhost.localdomain"
}
return &Email{conf: c, tmpl: t, logger: l, hostname: h}
return &Email{conf: c, tmpl: t, logger: l, hostname: h, templater: templater}
}
// auth resolves a string of authentication mechanisms.
@@ -199,9 +224,9 @@ func (n *Email) Notify(ctx context.Context, as ...*types.Alert) (bool, error) {
if ok, mech := c.Extension("AUTH"); ok {
auth, err := n.auth(mech)
if err != nil && err != errNoAuthUsernameConfigured {
if err != nil && !errors.Is(err, errNoAuthUsernameConfigured) {
return true, errors.WrapInternalf(err, errors.CodeInternal, "find auth mechanism")
} else if err == errNoAuthUsernameConfigured {
} else if errors.Is(err, errNoAuthUsernameConfigured) {
n.logger.DebugContext(ctx, "no auth username configured. Attempting to send email without authenticating")
}
if auth != nil {
@@ -245,6 +270,16 @@ func (n *Email) Notify(ctx context.Context, as ...*types.Alert) (bool, error) {
}
}
// Prepare the content for the email. subject, when non-empty, overrides
// the configured Subject header for this notification only. We deliberately
// do not mutate n.conf.Headers here: the config map is shared across
// concurrent notifications to the same receiver.
subject, htmlBody, err := n.prepareContent(ctx, as)
if err != nil {
n.logger.ErrorContext(ctx, "failed to prepare notification content", errors.Attr(err))
return false, err
}
// Send the email headers and body.
message, err := c.Data()
if err != nil {
@@ -262,6 +297,10 @@ func (n *Email) Notify(ctx context.Context, as ...*types.Alert) (bool, error) {
buffer := &bytes.Buffer{}
for header, t := range n.conf.Headers {
if header == "Subject" {
fmt.Fprintf(buffer, "%s: %s\r\n", header, mime.QEncoding.Encode("utf-8", subject))
continue
}
value, err := n.tmpl.ExecuteTextString(t, data)
if err != nil {
return false, errors.WrapInternalf(err, errors.CodeInternal, "execute %q header template", header)
@@ -336,7 +375,7 @@ func (n *Email) Notify(ctx context.Context, as ...*types.Alert) (bool, error) {
}
}
if len(n.conf.HTML) > 0 {
if htmlBody != "" {
// Html template
// Preferred alternative placed last per section 5.1.4 of RFC 2046
// https://www.ietf.org/rfc/rfc2046.txt
@@ -347,12 +386,8 @@ func (n *Email) Notify(ctx context.Context, as ...*types.Alert) (bool, error) {
if err != nil {
return false, errors.WrapInternalf(err, errors.CodeInternal, "create part for html template")
}
body, err := n.tmpl.ExecuteHTMLString(n.conf.HTML, data)
if err != nil {
return false, errors.WrapInternalf(err, errors.CodeInternal, "execute html template")
}
qw := quotedprintable.NewWriter(w)
_, err = qw.Write([]byte(body))
_, err = qw.Write([]byte(htmlBody))
if err != nil {
return true, errors.WrapInternalf(err, errors.CodeInternal, "write HTML part")
}
@@ -381,6 +416,124 @@ func (n *Email) Notify(ctx context.Context, as ...*types.Alert) (bool, error) {
return false, nil
}
// prepareContent returns a subject override (empty when the default config
// Subject should be used) and the HTML body for the email. Callers must treat
// the subject as local state and never write it back to n.conf.Headers.
func (n *Email) prepareContent(ctx context.Context, alerts []*types.Alert) (string, string, error) {
customTitle, customBody := alertmanagertemplate.ExtractTemplatesFromAnnotations(alerts)
result, err := n.templater.Expand(ctx, alertmanagertypes.ExpandRequest{
TitleTemplate: customTitle,
BodyTemplate: customBody,
DefaultTitleTemplate: n.conf.Headers["Subject"],
DefaultBodyTemplate: n.conf.HTML,
}, alerts)
if err != nil {
return "", "", err
}
subject := result.Title
if !result.IsDefaultBody {
// Custom-body path: render each expanded markdown body to HTML, then
// wrap the whole thing in the email.signoz.html layout (or fall
// back to plain <div> wrapping when the layout template is not loaded).
for i, body := range result.Body {
if body == "" {
continue
}
rendered, err := markdownrenderer.RenderHTML(body)
if err != nil {
return "", "", err
}
result.Body[i] = rendered
}
appendRelatedLinkButtons(alerts, result.Body)
html, err := n.renderLayout(result)
if err != nil {
n.logger.WarnContext(ctx, "custom email template rendering failed, falling back to plain <div> wrap", errors.Attr(err))
return subject, wrapBodiesAsDivs(result.Body), nil
}
return subject, html, nil
}
return subject, result.Body[0], nil
}
// renderLayout wraps result in the email.signoz.html HTML layout loaded
// into n.tmpl from the alertmanager templates config. Returns an error when the
// layout template is not defined (e.g. in tests where no templates are loaded)
// so prepareContent can fall back to plain <div> wrapping.
func (n *Email) renderLayout(result *alertmanagertypes.ExpandResult) (string, error) {
bodies := make([]htmltemplate.HTML, 0, len(result.Body))
for _, b := range result.Body {
bodies = append(bodies, htmltemplate.HTML(b))
}
data := layoutData{Title: result.Title, Bodies: bodies}
if result.NotificationData != nil {
data.NotificationTemplateData = *result.NotificationData
}
html, err := n.tmpl.ExecuteHTMLString(`{{ template "`+alertEmailLayoutTemplate+`" . }}`, data)
if err != nil {
return "", errors.WrapInternalf(err, errors.CodeInternal, "failed to render email layout")
}
return html, nil
}
// appendRelatedLinkButtons appends "View Related Logs/Traces" buttons to each
// per-alert body when the rule manager attached the corresponding annotation.
// bodies is positionally aligned with alerts (see alertmanagertemplate.Prepare);
// empty bodies are skipped so we never attach a button to an alert that produced
// no visible content.
func appendRelatedLinkButtons(alerts []*types.Alert, bodies []string) {
for i := range bodies {
if i >= len(alerts) || bodies[i] == "" {
continue
}
if link := alerts[i].Annotations[ruletypes.AnnotationRelatedLogs]; link != "" {
bodies[i] += htmlButton("View Related Logs", string(link))
}
if link := alerts[i].Annotations[ruletypes.AnnotationRelatedTraces]; link != "" {
bodies[i] += htmlButton("View Related Traces", string(link))
}
}
}
func wrapBodiesAsDivs(bodies []string) string {
var b strings.Builder
for _, part := range bodies {
if part == "" {
continue
}
b.WriteString("<div>")
b.WriteString(part)
b.WriteString("</div>")
}
return b.String()
}
func htmlButton(text, url string) string {
return fmt.Sprintf(`
<a href="%s" target="_blank" style="text-decoration: none;">
<button style="
padding: 6px 16px;
/* Default System Font */
font-family: sans-serif;
font-size: 14px;
font-weight: 500;
line-height: 1.5;
/* Light Theme & Dynamic Background (Solid) */
color: #111827;
background-color: #f9fafb;
/* Static Outline */
border: 1px solid #d1d5db;
border-radius: 4px;
cursor: pointer;
">
%s
</button>
</a>`, url, text)
}
type loginAuth struct {
username, password string
}

View File

@@ -8,6 +8,7 @@ import (
"context"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"net/url"
@@ -17,7 +18,10 @@ import (
"testing"
"time"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/emersion/go-smtp"
commoncfg "github.com/prometheus/common/config"
"github.com/prometheus/common/model"
@@ -42,6 +46,11 @@ const (
emailFrom = "alertmanager@example.com"
)
// testTemplater returns a Templater bound to tmpl with a discard logger.
func testTemplater(tmpl *template.Template) alertmanagertypes.Templater {
return alertmanagertemplate.New(tmpl, slog.New(slog.DiscardHandler))
}
// email represents an email returned by the MailDev REST API.
// See https://github.com/djfarrelly/MailDev/blob/master/docs/rest.md.
type email struct {
@@ -162,7 +171,7 @@ func notifyEmailWithContext(ctx context.Context, t *testing.T, cfg *config.Email
return nil, false, err
}
email := New(cfg, tmpl, promslog.NewNopLogger())
email := New(cfg, tmpl, promslog.NewNopLogger(), testTemplater(tmpl))
retry, err := email.Notify(ctx, firingAlert)
if err != nil {
@@ -706,7 +715,7 @@ func TestEmailRejected(t *testing.T) {
tmpl, firingAlert, err := prepare(cfg)
require.NoError(t, err)
e := New(cfg, tmpl, promslog.NewNopLogger())
e := New(cfg, tmpl, promslog.NewNopLogger(), testTemplater(tmpl))
// Send the alert to mock SMTP server.
retry, err := e.Notify(context.Background(), firingAlert)
@@ -1030,6 +1039,135 @@ func TestEmailImplicitTLS(t *testing.T) {
}
}
func TestPrepareContent(t *testing.T) {
t.Run("default title template; custom body template", func(t *testing.T) {
tmpl, err := template.FromGlobs([]string{})
require.NoError(t, err)
tmpl.ExternalURL, _ = url.Parse("http://am")
bodyTpl := "line $labels.instance"
a1 := &types.Alert{
Alert: model.Alert{
Labels: model.LabelSet{
model.LabelName("instance"): model.LabelValue("one"),
},
Annotations: model.LabelSet{
model.LabelName(ruletypes.AnnotationBodyTemplate): model.LabelValue(bodyTpl),
},
},
}
a2 := &types.Alert{
Alert: model.Alert{
Labels: model.LabelSet{
model.LabelName("instance"): model.LabelValue("two"),
},
Annotations: model.LabelSet{
model.LabelName(ruletypes.AnnotationBodyTemplate): model.LabelValue(bodyTpl),
},
},
}
alerts := []*types.Alert{a1, a2}
cfg := &config.EmailConfig{Headers: map[string]string{"Subject": "subj"}}
n := New(cfg, tmpl, promslog.NewNopLogger(), testTemplater(tmpl))
ctx := context.Background()
subject, htmlBody, err := n.prepareContent(ctx, alerts)
require.NoError(t, err)
require.Equal(t, "subj", subject)
require.Equal(t, "<div><p>line one</p>\n</div><div><p>line two</p>\n</div>", htmlBody)
})
t.Run("custom title template; default body HTML template", func(t *testing.T) {
tmpl, err := template.FromGlobs([]string{})
require.NoError(t, err)
tmpl.ExternalURL, _ = url.Parse("http://am")
firingAlert := &types.Alert{
Alert: model.Alert{
Labels: model.LabelSet{},
Annotations: model.LabelSet{
model.LabelName(ruletypes.AnnotationTitleTemplate): model.LabelValue("fixed from $alert.status"),
},
StartsAt: time.Now(),
EndsAt: time.Now().Add(time.Hour),
},
}
alerts := []*types.Alert{firingAlert}
cfg := &config.EmailConfig{
Headers: map[string]string{},
HTML: "Status: {{ .Status }}",
}
n := New(cfg, tmpl, promslog.NewNopLogger(), testTemplater(tmpl))
ctx := context.Background()
subject, htmlBody, err := n.prepareContent(ctx, alerts)
require.NoError(t, err)
require.Equal(t, "Status: firing", htmlBody)
require.Equal(t, "fixed from firing", subject)
})
t.Run("default template without HTML", func(t *testing.T) {
cfg := &config.EmailConfig{Headers: map[string]string{"Subject": "the email subject"}}
tmpl, err := template.FromGlobs([]string{})
require.NoError(t, err)
tmpl.ExternalURL, _ = url.Parse("http://am")
n := New(cfg, tmpl, promslog.NewNopLogger(), testTemplater(tmpl))
firingAlert := &types.Alert{
Alert: model.Alert{
Labels: model.LabelSet{},
StartsAt: time.Now(),
EndsAt: time.Now().Add(time.Hour),
},
}
alerts := []*types.Alert{firingAlert}
ctx := context.Background()
subject, htmlBody, err := n.prepareContent(ctx, alerts)
require.NoError(t, err)
require.Equal(t, "", htmlBody)
require.Equal(t, "the email subject", subject)
})
t.Run("custom title template; custom body template", func(t *testing.T) {
// Load the email.signoz.html layout into the notification template
// the same way the alertmanager server does via the templates config.
tmpl, err := template.FromGlobs([]string{"../../../../templates/alertmanager/*.gotmpl"})
require.NoError(t, err)
tmpl.ExternalURL, _ = url.Parse("http://am")
firingAlert := &types.Alert{
Alert: model.Alert{
Labels: model.LabelSet{
model.LabelName("instance"): model.LabelValue("two"),
},
Annotations: model.LabelSet{
model.LabelName(ruletypes.AnnotationTitleTemplate): model.LabelValue("fixed from $alert.status"),
model.LabelName(ruletypes.AnnotationBodyTemplate): model.LabelValue("line $labels.instance"),
},
StartsAt: time.Now(),
EndsAt: time.Now().Add(time.Hour),
},
}
alerts := []*types.Alert{firingAlert}
cfg := &config.EmailConfig{
Headers: map[string]string{"Subject": "subject"},
HTML: "Well, what are you?",
}
n := New(cfg, tmpl, promslog.NewNopLogger(), testTemplater(tmpl))
ctx := context.Background()
subject, htmlBody, err := n.prepareContent(ctx, alerts)
require.NoError(t, err)
require.Contains(t, htmlBody, "<!DOCTYPE html>")
require.Contains(t, htmlBody, "<p>line two</p>")
require.NotContains(t, htmlBody, "Well, what are you?")
require.Equal(t, subject, "fixed from firing")
require.NotContains(t, subject, "subject")
})
}
func ptrTo(b bool) *bool {
return &b
}

View File

@@ -15,7 +15,9 @@ import (
"slices"
"strings"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
commoncfg "github.com/prometheus/common/config"
"github.com/prometheus/common/model"
@@ -44,6 +46,7 @@ type Notifier struct {
retrier *notify.Retrier
webhookURL *config.SecretURL
postJSONFunc func(ctx context.Context, client *http.Client, url string, body io.Reader) (*http.Response, error)
templater alertmanagertypes.Templater
}
// https://learn.microsoft.com/en-us/connectors/teams/?tabs=text1#adaptivecarditemschema
@@ -52,7 +55,7 @@ type Content struct {
Type string `json:"type"`
Version string `json:"version"`
Body []Body `json:"body"`
Msteams Msteams `json:"msteams,omitempty"`
Msteams Msteams `json:"msteams,omitzero"`
Actions []Action `json:"actions"`
}
@@ -94,7 +97,7 @@ type teamsMessage struct {
}
// New returns a new notifier that uses the Microsoft Teams Power Platform connector.
func New(c *config.MSTeamsV2Config, t *template.Template, titleLink string, l *slog.Logger, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) {
func New(c *config.MSTeamsV2Config, t *template.Template, titleLink string, l *slog.Logger, templater alertmanagertypes.Templater, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) {
client, err := notify.NewClientWithTracing(*c.HTTPConfig, Integration, httpOpts...)
if err != nil {
return nil, err
@@ -109,6 +112,7 @@ func New(c *config.MSTeamsV2Config, t *template.Template, titleLink string, l *s
retrier: &notify.Retrier{},
webhookURL: c.WebhookURL,
postJSONFunc: notify.PostJSON,
templater: templater,
}
return n, nil
@@ -128,25 +132,11 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error)
return false, err
}
title := tmpl(n.conf.Title)
if err != nil {
return false, err
}
titleLink := tmpl(n.titleLink)
if err != nil {
return false, err
}
alerts := types.Alerts(as...)
color := colorGrey
switch alerts.Status() {
case model.AlertFiring:
color = colorRed
case model.AlertResolved:
color = colorGreen
}
var url string
if n.conf.WebhookURL != nil {
url = n.conf.WebhookURL.String()
@@ -158,6 +148,12 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error)
url = strings.TrimSpace(string(content))
}
bodyBlocks, err := n.prepareContent(ctx, as)
if err != nil {
n.logger.ErrorContext(ctx, "failed to prepare notification content", errors.Attr(err))
return false, err
}
// A message as referenced in https://learn.microsoft.com/en-us/connectors/teams/?tabs=text1%2Cdotnet#request-body-schema
t := teamsMessage{
Type: "message",
@@ -169,17 +165,7 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error)
Schema: "http://adaptivecards.io/schemas/adaptive-card.json",
Type: "AdaptiveCard",
Version: "1.2",
Body: []Body{
{
Type: "TextBlock",
Text: title,
Weight: "Bolder",
Size: "Medium",
Wrap: true,
Style: "heading",
Color: color,
},
},
Body: bodyBlocks,
Actions: []Action{
{
Type: "Action.OpenUrl",
@@ -195,20 +181,6 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error)
},
}
// add labels and annotations to the body of all alerts
for _, alert := range as {
t.Attachments[0].Content.Body = append(t.Attachments[0].Content.Body, Body{
Type: "TextBlock",
Text: "Alerts",
Weight: "Bolder",
Size: "Medium",
Wrap: true,
Color: color,
})
t.Attachments[0].Content.Body = append(t.Attachments[0].Content.Body, n.createLabelsAndAnnotationsBody(alert)...)
}
var payload bytes.Buffer
if err = json.NewEncoder(&payload).Encode(t); err != nil {
return false, err
@@ -228,6 +200,75 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error)
return shouldRetry, err
}
// prepareContent builds the Adaptive Card body blocks for the notification.
// The first block is always the title; the remainder depends on whether the
// alerts carried a custom body template.
func (n *Notifier) prepareContent(ctx context.Context, alerts []*types.Alert) ([]Body, error) {
customTitle, customBody := alertmanagertemplate.ExtractTemplatesFromAnnotations(alerts)
result, err := n.templater.Expand(ctx, alertmanagertypes.ExpandRequest{
TitleTemplate: customTitle,
BodyTemplate: customBody,
DefaultTitleTemplate: n.conf.Title,
DefaultBodyTemplate: n.conf.Text,
}, alerts)
if err != nil {
return nil, err
}
color := colorGrey
switch types.Alerts(alerts...).Status() {
case model.AlertFiring:
color = colorRed
case model.AlertResolved:
color = colorGreen
}
blocks := []Body{{
Type: "TextBlock",
Text: result.Title,
Weight: "Bolder",
Size: "Medium",
Wrap: true,
Style: "heading",
Color: color,
}}
if result.IsDefaultBody {
for _, alert := range alerts {
blocks = append(blocks, Body{
Type: "TextBlock",
Text: "Alerts",
Weight: "Bolder",
Size: "Medium",
Wrap: true,
Color: color,
})
blocks = append(blocks, n.createLabelsAndAnnotationsBody(alert)...)
}
return blocks, nil
}
// Custom body path: result.Body is positionally aligned with alerts;
// entries for alerts whose template rendered empty are kept as "" so we
// can skip them here without shifting the per-alert color index.
for i, body := range result.Body {
if body == "" || i >= len(alerts) {
continue
}
perAlertColor := colorRed
if alerts[i].Resolved() {
perAlertColor = colorGreen
}
blocks = append(blocks, Body{
Type: "TextBlock",
Text: body,
Wrap: true,
Color: perAlertColor,
})
}
return blocks, nil
}
func (*Notifier) createLabelsAndAnnotationsBody(alert *types.Alert) []Body {
bodies := []Body{}
bodies = append(bodies, Body{
@@ -258,7 +299,8 @@ func (*Notifier) createLabelsAndAnnotationsBody(alert *types.Alert) []Body {
annotationsFacts := []Fact{}
for k, v := range alert.Annotations {
if slices.Contains([]string{"summary", "related_logs", "related_traces"}, string(k)) {
if slices.Contains([]string{"summary", "related_logs", "related_traces"}, string(k)) ||
alertmanagertypes.IsPrivateAnnotation(string(k)) {
continue
}
annotationsFacts = append(annotationsFacts, Fact{Title: string(k), Value: string(v)})

View File

@@ -8,6 +8,7 @@ import (
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
@@ -15,6 +16,9 @@ import (
"testing"
"time"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
commoncfg "github.com/prometheus/common/config"
"github.com/prometheus/common/model"
"github.com/prometheus/common/promslog"
@@ -23,21 +27,28 @@ import (
test "github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/alertmanagernotifytest"
"github.com/prometheus/alertmanager/config"
"github.com/prometheus/alertmanager/notify"
"github.com/prometheus/alertmanager/template"
"github.com/prometheus/alertmanager/types"
)
func newTestTemplater(tmpl *template.Template) alertmanagertypes.Templater {
return alertmanagertemplate.New(tmpl, slog.New(slog.DiscardHandler))
}
// This is a test URL that has been modified to not be valid.
var testWebhookURL, _ = url.Parse("https://example.westeurope.logic.azure.com:443/workflows/xxx/triggers/manual/paths/invoke?api-version=2016-06-01&sp=%2Ftriggers%2Fmanual%2Frun&sv=1.0&sig=xxx")
func TestMSTeamsV2Retry(t *testing.T) {
tmpl := test.CreateTmpl(t)
notifier, err := New(
&config.MSTeamsV2Config{
WebhookURL: &config.SecretURL{URL: testWebhookURL},
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
test.CreateTmpl(t),
tmpl,
`{{ template "msteamsv2.default.titleLink" . }}`,
promslog.NewNopLogger(),
newTestTemplater(tmpl),
)
require.NoError(t, err)
@@ -64,14 +75,16 @@ func TestNotifier_Notify_WithReason(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpl := test.CreateTmpl(t)
notifier, err := New(
&config.MSTeamsV2Config{
WebhookURL: &config.SecretURL{URL: testWebhookURL},
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
test.CreateTmpl(t),
tmpl,
`{{ template "msteamsv2.default.titleLink" . }}`,
promslog.NewNopLogger(),
newTestTemplater(tmpl),
)
require.NoError(t, err)
@@ -153,7 +166,8 @@ func TestMSTeamsV2Templating(t *testing.T) {
t.Run(tc.title, func(t *testing.T) {
tc.cfg.WebhookURL = &config.SecretURL{URL: u}
tc.cfg.HTTPConfig = &commoncfg.HTTPClientConfig{}
pd, err := New(tc.cfg, test.CreateTmpl(t), tc.titleLink, promslog.NewNopLogger())
tmpl := test.CreateTmpl(t)
pd, err := New(tc.cfg, tmpl, tc.titleLink, promslog.NewNopLogger(), newTestTemplater(tmpl))
require.NoError(t, err)
ctx := context.Background()
@@ -186,20 +200,124 @@ func TestMSTeamsV2RedactedURL(t *testing.T) {
defer fn()
secret := "secret"
tmpl := test.CreateTmpl(t)
notifier, err := New(
&config.MSTeamsV2Config{
WebhookURL: &config.SecretURL{URL: u},
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
test.CreateTmpl(t),
tmpl,
`{{ template "msteamsv2.default.titleLink" . }}`,
promslog.NewNopLogger(),
newTestTemplater(tmpl),
)
require.NoError(t, err)
test.AssertNotifyLeaksNoSecret(ctx, t, notifier, secret)
}
func TestPrepareContent(t *testing.T) {
t.Run("default template - firing alerts", func(t *testing.T) {
tmpl := test.CreateTmpl(t)
notifier, err := New(
&config.MSTeamsV2Config{
WebhookURL: &config.SecretURL{URL: testWebhookURL},
HTTPConfig: &commoncfg.HTTPClientConfig{},
Title: "Alertname: {{ .CommonLabels.alertname }}",
},
tmpl,
`{{ template "msteamsv2.default.titleLink" . }}`,
promslog.NewNopLogger(),
newTestTemplater(tmpl),
)
require.NoError(t, err)
ctx := context.Background()
ctx = notify.WithGroupKey(ctx, "1")
alerts := []*types.Alert{
{
Alert: model.Alert{
Labels: model.LabelSet{"alertname": "test"},
// Custom body template
Annotations: model.LabelSet{
ruletypes.AnnotationBodyTemplate: "Firing alert: $alertname",
},
StartsAt: time.Now(),
EndsAt: time.Now().Add(time.Hour),
},
},
}
blocks, err := notifier.prepareContent(ctx, alerts)
require.NoError(t, err)
require.NotEmpty(t, blocks)
// First block should be the title with color (firing = red)
require.Equal(t, "Bolder", blocks[0].Weight)
require.Equal(t, colorRed, blocks[0].Color)
// verify title text
require.Equal(t, "Alertname: test", blocks[0].Text)
// verify body text
require.Equal(t, "Firing alert: test", blocks[1].Text)
})
t.Run("custom template - per-alert color", func(t *testing.T) {
tmpl := test.CreateTmpl(t)
notifier, err := New(
&config.MSTeamsV2Config{
WebhookURL: &config.SecretURL{URL: testWebhookURL},
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
tmpl,
`{{ template "msteamsv2.default.titleLink" . }}`,
promslog.NewNopLogger(),
newTestTemplater(tmpl),
)
require.NoError(t, err)
ctx := context.Background()
ctx = notify.WithGroupKey(ctx, "1")
alerts := []*types.Alert{
{
Alert: model.Alert{
Labels: model.LabelSet{"alertname": "test1"},
Annotations: model.LabelSet{
"summary": "test",
ruletypes.AnnotationTitleTemplate: "Custom Title",
ruletypes.AnnotationBodyTemplate: "custom body $alertname",
},
StartsAt: time.Now(),
EndsAt: time.Now().Add(time.Hour),
},
},
{
Alert: model.Alert{
Labels: model.LabelSet{"alertname": "test2"},
Annotations: model.LabelSet{
"summary": "test",
ruletypes.AnnotationTitleTemplate: "Custom Title",
ruletypes.AnnotationBodyTemplate: "custom body $alertname",
},
StartsAt: time.Now().Add(-time.Hour),
EndsAt: time.Now().Add(-time.Minute),
},
},
}
blocks, err := notifier.prepareContent(ctx, alerts)
require.NoError(t, err)
require.NotEmpty(t, blocks)
// total 3 blocks: title and 2 body blocks
require.True(t, len(blocks) == 3)
// First block: title color is overall color of the alerts
require.Equal(t, colorRed, blocks[0].Color)
// verify title text
require.Equal(t, "Custom Title", blocks[0].Text)
// Body blocks should have per-alert color
require.Equal(t, colorRed, blocks[1].Color) // firing
require.Equal(t, colorGreen, blocks[2].Color) // resolved
})
}
func TestMSTeamsV2ReadingURLFromFile(t *testing.T) {
ctx, u, fn := test.GetContextWithCancelingURL()
defer fn()
@@ -209,14 +327,16 @@ func TestMSTeamsV2ReadingURLFromFile(t *testing.T) {
_, err = f.WriteString(u.String() + "\n")
require.NoError(t, err, "writing to temp file failed")
tmpl := test.CreateTmpl(t)
notifier, err := New(
&config.MSTeamsV2Config{
WebhookURLFile: f.Name(),
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
test.CreateTmpl(t),
tmpl,
`{{ template "msteamsv2.default.titleLink" . }}`,
promslog.NewNopLogger(),
newTestTemplater(tmpl),
)
require.NoError(t, err)

View File

@@ -15,7 +15,10 @@ import (
"os"
"strings"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/templating/markdownrenderer"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
commoncfg "github.com/prometheus/common/config"
"github.com/prometheus/common/model"
@@ -34,25 +37,27 @@ const maxMessageLenRunes = 130
// Notifier implements a Notifier for OpsGenie notifications.
type Notifier struct {
conf *config.OpsGenieConfig
tmpl *template.Template
logger *slog.Logger
client *http.Client
retrier *notify.Retrier
conf *config.OpsGenieConfig
tmpl *template.Template
logger *slog.Logger
client *http.Client
retrier *notify.Retrier
templater alertmanagertypes.Templater
}
// New returns a new OpsGenie notifier.
func New(c *config.OpsGenieConfig, t *template.Template, l *slog.Logger, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) {
func New(c *config.OpsGenieConfig, t *template.Template, l *slog.Logger, templater alertmanagertypes.Templater, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) {
client, err := notify.NewClientWithTracing(*c.HTTPConfig, Integration, httpOpts...)
if err != nil {
return nil, err
}
return &Notifier{
conf: c,
tmpl: t,
logger: l,
client: client,
retrier: &notify.Retrier{RetryCodes: []int{http.StatusTooManyRequests}},
conf: c,
tmpl: t,
logger: l,
client: client,
retrier: &notify.Retrier{RetryCodes: []int{http.StatusTooManyRequests}},
templater: templater,
}, nil
}
@@ -123,6 +128,55 @@ func safeSplit(s, sep string) []string {
return b
}
// prepareContent expands alert templates and returns the OpsGenie-ready title
// (truncated to the 130-rune limit) and HTML description. Custom bodies are
// rendered to HTML and stitched together with <hr> dividers; default bodies
// are joined with newlines (OpsGenie's legacy plain-text description).
func (n *Notifier) prepareContent(ctx context.Context, alerts []*types.Alert) (string, string, error) {
customTitle, customBody := alertmanagertemplate.ExtractTemplatesFromAnnotations(alerts)
result, err := n.templater.Expand(ctx, alertmanagertypes.ExpandRequest{
TitleTemplate: customTitle,
BodyTemplate: customBody,
DefaultTitleTemplate: n.conf.Message,
DefaultBodyTemplate: n.conf.Description,
}, alerts)
if err != nil {
return "", "", err
}
var description string
if result.IsDefaultBody {
description = strings.Join(result.Body, "\n")
} else {
var b strings.Builder
first := true
for _, part := range result.Body {
if part == "" {
continue
}
rendered, renderErr := markdownrenderer.RenderHTML(part)
if renderErr != nil {
return "", "", renderErr
}
if !first {
b.WriteString("<hr>")
}
b.WriteString("<div>")
b.WriteString(rendered)
b.WriteString("</div>")
first = false
}
description = b.String()
}
title, truncated := notify.TruncateInRunes(result.Title, maxMessageLenRunes)
if truncated {
n.logger.WarnContext(ctx, "Truncated message", slog.Int("max_runes", maxMessageLenRunes))
}
return title, description, nil
}
// Create requests for a list of alerts.
func (n *Notifier) createRequests(ctx context.Context, as ...*types.Alert) ([]*http.Request, bool, error) {
key, err := notify.ExtractGroupKey(ctx)
@@ -168,9 +222,10 @@ func (n *Notifier) createRequests(ctx context.Context, as ...*types.Alert) ([]*h
}
requests = append(requests, req.WithContext(ctx))
default:
message, truncated := notify.TruncateInRunes(tmpl(n.conf.Message), maxMessageLenRunes)
if truncated {
logger.WarnContext(ctx, "Truncated message", slog.Any("alert", key), slog.Int("max_runes", maxMessageLenRunes))
message, description, err := n.prepareContent(ctx, as)
if err != nil {
n.logger.ErrorContext(ctx, "failed to prepare notification content", errors.Attr(err))
return nil, false, err
}
createEndpointURL := n.conf.APIURL.Copy()
@@ -209,7 +264,7 @@ func (n *Notifier) createRequests(ctx context.Context, as ...*types.Alert) ([]*h
msg := &opsGenieCreateMessage{
Alias: alias,
Message: message,
Description: tmpl(n.conf.Description),
Description: description,
Details: details,
Source: tmpl(n.conf.Source),
Responders: responders,

View File

@@ -8,12 +8,16 @@ import (
"context"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"os"
"testing"
"time"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
commoncfg "github.com/prometheus/common/config"
"github.com/prometheus/common/model"
"github.com/prometheus/common/promslog"
@@ -22,16 +26,23 @@ import (
"github.com/prometheus/alertmanager/config"
"github.com/prometheus/alertmanager/notify"
"github.com/prometheus/alertmanager/notify/test"
"github.com/prometheus/alertmanager/template"
"github.com/prometheus/alertmanager/types"
)
func newTestTemplater(tmpl *template.Template) alertmanagertypes.Templater {
return alertmanagertemplate.New(tmpl, slog.New(slog.DiscardHandler))
}
func TestOpsGenieRetry(t *testing.T) {
tmpl := test.CreateTmpl(t)
notifier, err := New(
&config.OpsGenieConfig{
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
test.CreateTmpl(t),
tmpl,
promslog.NewNopLogger(),
newTestTemplater(tmpl),
)
require.NoError(t, err)
@@ -47,14 +58,16 @@ func TestOpsGenieRedactedURL(t *testing.T) {
defer fn()
key := "key"
tmpl := test.CreateTmpl(t)
notifier, err := New(
&config.OpsGenieConfig{
APIURL: &config.URL{URL: u},
APIKey: config.Secret(key),
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
test.CreateTmpl(t),
tmpl,
promslog.NewNopLogger(),
newTestTemplater(tmpl),
)
require.NoError(t, err)
@@ -72,14 +85,16 @@ func TestGettingOpsGegineApikeyFromFile(t *testing.T) {
_, err = f.WriteString(key)
require.NoError(t, err, "writing to temp file failed")
tmpl := test.CreateTmpl(t)
notifier, err := New(
&config.OpsGenieConfig{
APIURL: &config.URL{URL: u},
APIKeyFile: f.Name(),
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
test.CreateTmpl(t),
tmpl,
promslog.NewNopLogger(),
newTestTemplater(tmpl),
)
require.NoError(t, err)
@@ -202,7 +217,7 @@ func TestOpsGenie(t *testing.T) {
},
} {
t.Run(tc.title, func(t *testing.T) {
notifier, err := New(tc.cfg, tmpl, logger)
notifier, err := New(tc.cfg, tmpl, logger, newTestTemplater(tmpl))
require.NoError(t, err)
ctx := context.Background()
@@ -278,7 +293,7 @@ func TestOpsGenieWithUpdate(t *testing.T) {
APIURL: &config.URL{URL: u},
HTTPConfig: &commoncfg.HTTPClientConfig{},
}
notifierWithUpdate, err := New(&opsGenieConfigWithUpdate, tmpl, promslog.NewNopLogger())
notifierWithUpdate, err := New(&opsGenieConfigWithUpdate, tmpl, promslog.NewNopLogger(), newTestTemplater(tmpl))
alert := &types.Alert{
Alert: model.Alert{
StartsAt: time.Now(),
@@ -321,7 +336,7 @@ func TestOpsGenieApiKeyFile(t *testing.T) {
APIURL: &config.URL{URL: u},
HTTPConfig: &commoncfg.HTTPClientConfig{},
}
notifierWithUpdate, err := New(&opsGenieConfigWithUpdate, tmpl, promslog.NewNopLogger())
notifierWithUpdate, err := New(&opsGenieConfigWithUpdate, tmpl, promslog.NewNopLogger(), newTestTemplater(tmpl))
require.NoError(t, err)
requests, _, err := notifierWithUpdate.createRequests(ctx)
@@ -329,6 +344,99 @@ func TestOpsGenieApiKeyFile(t *testing.T) {
require.Equal(t, "GenieKey my_secret_api_key", requests[0].Header.Get("Authorization"))
}
func TestPrepareContent(t *testing.T) {
t.Run("default template", func(t *testing.T) {
tmpl := test.CreateTmpl(t)
logger := promslog.NewNopLogger()
notifier := &Notifier{
conf: &config.OpsGenieConfig{
Message: `{{ .CommonLabels.Message }}`,
Description: `{{ .CommonLabels.Description }}`,
},
tmpl: tmpl,
logger: logger,
templater: newTestTemplater(tmpl),
}
ctx := context.Background()
ctx = notify.WithGroupKey(ctx, "1")
alert := &types.Alert{
Alert: model.Alert{
Labels: model.LabelSet{
"Message": "Firing alert: test",
"Description": "Check runbook for more details",
},
StartsAt: time.Now(),
EndsAt: time.Now().Add(time.Hour),
},
}
alerts := []*types.Alert{alert}
title, desc, prepErr := notifier.prepareContent(ctx, alerts)
require.NoError(t, prepErr)
require.Equal(t, "Firing alert: test", title)
require.Equal(t, "Check runbook for more details", desc)
})
t.Run("custom template", func(t *testing.T) {
tmpl := test.CreateTmpl(t)
logger := promslog.NewNopLogger()
notifier := &Notifier{
conf: &config.OpsGenieConfig{
Message: `{{ .CommonLabels.Message }}`,
Description: `{{ .CommonLabels.Description }}`,
},
tmpl: tmpl,
logger: logger,
templater: newTestTemplater(tmpl),
}
ctx := context.Background()
ctx = notify.WithGroupKey(ctx, "1")
alert1 := &types.Alert{
Alert: model.Alert{
Labels: model.LabelSet{
"service": "payment",
"namespace": "potter-the-harry",
},
Annotations: model.LabelSet{
ruletypes.AnnotationTitleTemplate: "High request throughput for $service",
ruletypes.AnnotationBodyTemplate: "Alert firing in NS: $labels.namespace",
},
StartsAt: time.Now(),
EndsAt: time.Now().Add(time.Hour),
},
}
alert2 := &types.Alert{
Alert: model.Alert{
Labels: model.LabelSet{
"service": "payment",
"namespace": "smart-the-rat",
},
Annotations: model.LabelSet{
ruletypes.AnnotationTitleTemplate: "High request throughput for $service",
ruletypes.AnnotationBodyTemplate: "Alert firing in NS: $labels.namespace",
},
StartsAt: time.Now(),
EndsAt: time.Now().Add(time.Hour),
},
}
alerts := []*types.Alert{alert1, alert2}
title, desc, err := notifier.prepareContent(ctx, alerts)
require.NoError(t, err)
require.Equal(t, "High request throughput for payment", title)
// Each alert body wrapped in <div>, separated by <hr>
require.Equal(t, "<div><p>Alert firing in NS: potter-the-harry</p>\n</div><hr><div><p>Alert firing in NS: smart-the-rat</p>\n</div>", desc)
})
}
func readBody(t *testing.T, r *http.Request) string {
t.Helper()
body, err := io.ReadAll(r.Body)

View File

@@ -15,7 +15,9 @@ import (
"os"
"strings"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/alecthomas/units"
commoncfg "github.com/prometheus/common/config"
"github.com/prometheus/common/model"
@@ -40,21 +42,22 @@ const (
// Notifier implements a Notifier for PagerDuty notifications.
type Notifier struct {
conf *config.PagerdutyConfig
tmpl *template.Template
logger *slog.Logger
apiV1 string // for tests.
client *http.Client
retrier *notify.Retrier
conf *config.PagerdutyConfig
tmpl *template.Template
logger *slog.Logger
apiV1 string // for tests.
client *http.Client
retrier *notify.Retrier
templater alertmanagertypes.Templater
}
// New returns a new PagerDuty notifier.
func New(c *config.PagerdutyConfig, t *template.Template, l *slog.Logger, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) {
func New(c *config.PagerdutyConfig, t *template.Template, l *slog.Logger, templater alertmanagertypes.Templater, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) {
client, err := notify.NewClientWithTracing(*c.HTTPConfig, Integration, httpOpts...)
if err != nil {
return nil, err
}
n := &Notifier{conf: c, tmpl: t, logger: l, client: client}
n := &Notifier{conf: c, tmpl: t, logger: l, client: client, templater: templater}
if c.ServiceKey != "" || c.ServiceKeyFile != "" {
n.apiV1 = "https://events.pagerduty.com/generic/2010-04-15/create_event.json"
// Retrying can solve the issue on 403 (rate limiting) and 5xx response codes.
@@ -143,11 +146,12 @@ func (n *Notifier) notifyV1(
key notify.Key,
data *template.Data,
details map[string]any,
title string,
) (bool, error) {
var tmplErr error
tmpl := notify.TmplText(n.tmpl, data, &tmplErr)
description, truncated := notify.TruncateInRunes(tmpl(n.conf.Description), maxV1DescriptionLenRunes)
description, truncated := notify.TruncateInRunes(title, maxV1DescriptionLenRunes)
if truncated {
n.logger.WarnContext(ctx, "Truncated description", slog.Any("key", key), slog.Int("max_runes", maxV1DescriptionLenRunes))
}
@@ -203,6 +207,7 @@ func (n *Notifier) notifyV2(
key notify.Key,
data *template.Data,
details map[string]any,
title string,
) (bool, error) {
var tmplErr error
tmpl := notify.TmplText(n.tmpl, data, &tmplErr)
@@ -211,7 +216,7 @@ func (n *Notifier) notifyV2(
n.conf.Severity = "error"
}
summary, truncated := notify.TruncateInRunes(tmpl(n.conf.Description), maxV2SummaryLenRunes)
summary, truncated := notify.TruncateInRunes(title, maxV2SummaryLenRunes)
if truncated {
n.logger.WarnContext(ctx, "Truncated summary", slog.Any("key", key), slog.Int("max_runes", maxV2SummaryLenRunes))
}
@@ -294,6 +299,22 @@ func (n *Notifier) notifyV2(
return retry, err
}
// prepareTitle expands the notification title. PagerDuty has no body surface
// we care about — the description/summary field is what users see as the
// incident headline, so we feed the configured Description as the default
// title template and ignore any custom body_template entirely.
func (n *Notifier) prepareTitle(ctx context.Context, alerts []*types.Alert) (string, error) {
customTitle, _ := alertmanagertemplate.ExtractTemplatesFromAnnotations(alerts)
result, err := n.templater.Expand(ctx, alertmanagertypes.ExpandRequest{
TitleTemplate: customTitle,
DefaultTitleTemplate: n.conf.Description,
}, alerts)
if err != nil {
return "", err
}
return result.Title, nil
}
// Notify implements the Notifier interface.
func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) {
key, err := notify.ExtractGroupKey(ctx)
@@ -302,6 +323,12 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error)
}
logger := n.logger.With(slog.Any("group_key", key))
title, err := n.prepareTitle(ctx, as)
if err != nil {
n.logger.ErrorContext(ctx, "failed to prepare notification content", errors.Attr(err))
return false, err
}
var (
alerts = types.Alerts(as...)
data = notify.GetTemplateData(ctx, n.tmpl, as, logger)
@@ -329,7 +356,7 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error)
if n.apiV1 != "" {
nf = n.notifyV1
}
retry, err := nf(ctx, eventType, key, data, details)
retry, err := nf(ctx, eventType, key, data, details, title)
if err != nil {
if ctx.Err() != nil {
err = errors.WrapInternalf(err, errors.CodeInternal, "failed to notify PagerDuty: %v", context.Cause(ctx))

View File

@@ -9,6 +9,7 @@ import (
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
@@ -17,7 +18,10 @@ import (
"testing"
"time"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
commoncfg "github.com/prometheus/common/config"
"github.com/prometheus/common/model"
"github.com/prometheus/common/promslog"
@@ -30,14 +34,20 @@ import (
"github.com/prometheus/alertmanager/types"
)
func newTestTemplater(tmpl *template.Template) alertmanagertypes.Templater {
return alertmanagertemplate.New(tmpl, slog.New(slog.DiscardHandler))
}
func TestPagerDutyRetryV1(t *testing.T) {
tmpl := test.CreateTmpl(t)
notifier, err := New(
&config.PagerdutyConfig{
ServiceKey: config.Secret("01234567890123456789012345678901"),
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
test.CreateTmpl(t),
tmpl,
promslog.NewNopLogger(),
newTestTemplater(tmpl),
)
require.NoError(t, err)
@@ -49,13 +59,15 @@ func TestPagerDutyRetryV1(t *testing.T) {
}
func TestPagerDutyRetryV2(t *testing.T) {
tmpl := test.CreateTmpl(t)
notifier, err := New(
&config.PagerdutyConfig{
RoutingKey: config.Secret("01234567890123456789012345678901"),
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
test.CreateTmpl(t),
tmpl,
promslog.NewNopLogger(),
newTestTemplater(tmpl),
)
require.NoError(t, err)
@@ -71,13 +83,15 @@ func TestPagerDutyRedactedURLV1(t *testing.T) {
defer fn()
key := "01234567890123456789012345678901"
tmpl := test.CreateTmpl(t)
notifier, err := New(
&config.PagerdutyConfig{
ServiceKey: config.Secret(key),
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
test.CreateTmpl(t),
tmpl,
promslog.NewNopLogger(),
newTestTemplater(tmpl),
)
require.NoError(t, err)
notifier.apiV1 = u.String()
@@ -90,14 +104,16 @@ func TestPagerDutyRedactedURLV2(t *testing.T) {
defer fn()
key := "01234567890123456789012345678901"
tmpl := test.CreateTmpl(t)
notifier, err := New(
&config.PagerdutyConfig{
URL: &config.URL{URL: u},
RoutingKey: config.Secret(key),
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
test.CreateTmpl(t),
tmpl,
promslog.NewNopLogger(),
newTestTemplater(tmpl),
)
require.NoError(t, err)
@@ -114,13 +130,15 @@ func TestPagerDutyV1ServiceKeyFromFile(t *testing.T) {
ctx, u, fn := test.GetContextWithCancelingURL()
defer fn()
tmpl := test.CreateTmpl(t)
notifier, err := New(
&config.PagerdutyConfig{
ServiceKeyFile: f.Name(),
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
test.CreateTmpl(t),
tmpl,
promslog.NewNopLogger(),
newTestTemplater(tmpl),
)
require.NoError(t, err)
notifier.apiV1 = u.String()
@@ -138,14 +156,16 @@ func TestPagerDutyV2RoutingKeyFromFile(t *testing.T) {
ctx, u, fn := test.GetContextWithCancelingURL()
defer fn()
tmpl := test.CreateTmpl(t)
notifier, err := New(
&config.PagerdutyConfig{
URL: &config.URL{URL: u},
RoutingKeyFile: f.Name(),
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
test.CreateTmpl(t),
tmpl,
promslog.NewNopLogger(),
newTestTemplater(tmpl),
)
require.NoError(t, err)
@@ -302,7 +322,8 @@ func TestPagerDutyTemplating(t *testing.T) {
t.Run(tc.title, func(t *testing.T) {
tc.cfg.URL = &config.URL{URL: u}
tc.cfg.HTTPConfig = &commoncfg.HTTPClientConfig{}
pd, err := New(tc.cfg, test.CreateTmpl(t), promslog.NewNopLogger())
tmpl := test.CreateTmpl(t)
pd, err := New(tc.cfg, tmpl, promslog.NewNopLogger(), newTestTemplater(tmpl))
require.NoError(t, err)
if pd.apiV1 != "" {
pd.apiV1 = u.String()
@@ -392,13 +413,15 @@ func TestEventSizeEnforcement(t *testing.T) {
Details: bigDetailsV1,
}
tmpl := test.CreateTmpl(t)
notifierV1, err := New(
&config.PagerdutyConfig{
ServiceKey: config.Secret("01234567890123456789012345678901"),
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
test.CreateTmpl(t),
tmpl,
promslog.NewNopLogger(),
newTestTemplater(tmpl),
)
require.NoError(t, err)
@@ -420,8 +443,9 @@ func TestEventSizeEnforcement(t *testing.T) {
RoutingKey: config.Secret("01234567890123456789012345678901"),
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
test.CreateTmpl(t),
tmpl,
promslog.NewNopLogger(),
newTestTemplater(tmpl),
)
require.NoError(t, err)
@@ -536,7 +560,8 @@ func TestPagerDutyEmptySrcHref(t *testing.T) {
Links: links,
}
pagerDuty, err := New(&pagerDutyConfig, test.CreateTmpl(t), promslog.NewNopLogger())
pdTmpl := test.CreateTmpl(t)
pagerDuty, err := New(&pagerDutyConfig, pdTmpl, promslog.NewNopLogger(), newTestTemplater(pdTmpl))
require.NoError(t, err)
ctx := context.Background()
@@ -603,7 +628,8 @@ func TestPagerDutyTimeout(t *testing.T) {
Timeout: tt.timeout,
}
pd, err := New(&cfg, test.CreateTmpl(t), promslog.NewNopLogger())
tmpl := test.CreateTmpl(t)
pd, err := New(&cfg, tmpl, promslog.NewNopLogger(), newTestTemplater(tmpl))
require.NoError(t, err)
ctx := context.Background()
@@ -881,3 +907,79 @@ func TestRenderDetails(t *testing.T) {
})
}
}
func TestPrepareContent(t *testing.T) {
prepareContext := func() context.Context {
ctx := context.Background()
ctx = notify.WithGroupKey(ctx, "1")
ctx = notify.WithReceiverName(ctx, "test-receiver")
ctx = notify.WithGroupLabels(ctx, model.LabelSet{"alertname": "HighCPU for Payment service"})
return ctx
}
t.Run("default template uses go text template config for title", func(t *testing.T) {
tmpl := test.CreateTmpl(t)
notifier, err := New(
&config.PagerdutyConfig{
RoutingKey: config.Secret("01234567890123456789012345678901"),
HTTPConfig: &commoncfg.HTTPClientConfig{},
Description: `{{ .CommonLabels.alertname }} ({{ .Status | toUpper }})`,
},
tmpl,
promslog.NewNopLogger(),
newTestTemplater(tmpl),
)
require.NoError(t, err)
ctx := prepareContext()
alerts := []*types.Alert{
{
Alert: model.Alert{
Labels: model.LabelSet{"alertname": "HighCPU for Payment service"},
StartsAt: time.Now(),
EndsAt: time.Now().Add(time.Hour),
},
},
}
title, err := notifier.prepareTitle(ctx, alerts)
require.NoError(t, err)
require.Equal(t, "HighCPU for Payment service (FIRING)", title)
})
t.Run("custom template uses $variable annotation for title", func(t *testing.T) {
tmpl := test.CreateTmpl(t)
notifier, err := New(
&config.PagerdutyConfig{
RoutingKey: config.Secret("01234567890123456789012345678901"),
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
tmpl,
promslog.NewNopLogger(),
newTestTemplater(tmpl),
)
require.NoError(t, err)
ctx := prepareContext()
alerts := []*types.Alert{
{
Alert: model.Alert{
Labels: model.LabelSet{
"alertname": "HighCPU",
"service": "api-server",
},
Annotations: model.LabelSet{
ruletypes.AnnotationTitleTemplate: "$rule.name on $service is in $alert.status state",
},
StartsAt: time.Now().Add(-time.Hour),
EndsAt: time.Now(),
},
},
}
title, err := notifier.prepareTitle(ctx, alerts)
require.NoError(t, err)
require.Equal(t, "HighCPU on api-server is in resolved state", title)
})
}

View File

@@ -26,7 +26,7 @@ var customNotifierIntegrations = []string{
msteamsv2.Integration,
}
func NewReceiverIntegrations(nc alertmanagertypes.Receiver, tmpl *template.Template, logger *slog.Logger) ([]notify.Integration, error) {
func NewReceiverIntegrations(nc alertmanagertypes.Receiver, tmpl *template.Template, logger *slog.Logger, templater alertmanagertypes.Templater) ([]notify.Integration, error) {
upstreamIntegrations, err := receiver.BuildReceiverIntegrations(nc, tmpl, logger)
if err != nil {
return nil, err
@@ -53,23 +53,25 @@ func NewReceiverIntegrations(nc alertmanagertypes.Receiver, tmpl *template.Templ
}
for i, c := range nc.WebhookConfigs {
add(webhook.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return webhook.New(c, tmpl, l) })
add(webhook.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return webhook.New(c, tmpl, l, templater) })
}
for i, c := range nc.EmailConfigs {
add(email.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return email.New(c, tmpl, l), nil })
add(email.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) {
return email.New(c, tmpl, l, templater), nil
})
}
for i, c := range nc.PagerdutyConfigs {
add(pagerduty.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return pagerduty.New(c, tmpl, l) })
add(pagerduty.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return pagerduty.New(c, tmpl, l, templater) })
}
for i, c := range nc.OpsGenieConfigs {
add(opsgenie.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return opsgenie.New(c, tmpl, l) })
add(opsgenie.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return opsgenie.New(c, tmpl, l, templater) })
}
for i, c := range nc.SlackConfigs {
add(slack.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return slack.New(c, tmpl, l) })
add(slack.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return slack.New(c, tmpl, l, templater) })
}
for i, c := range nc.MSTeamsV2Configs {
add(msteamsv2.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) {
return msteamsv2.New(c, tmpl, `{{ template "msteamsv2.default.titleLink" . }}`, l)
return msteamsv2.New(c, tmpl, `{{ template "msteamsv2.default.titleLink" . }}`, l, templater)
})
}

View File

@@ -14,7 +14,11 @@ import (
"os"
"strings"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/templating/markdownrenderer"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
commoncfg "github.com/prometheus/common/config"
"github.com/prometheus/alertmanager/config"
@@ -25,6 +29,8 @@ import (
const (
Integration = "slack"
colorRed = "#FF0000"
colorGreen = "#00FF00"
)
// https://api.slack.com/reference/messaging/attachments#legacy_fields - 1024, no units given, assuming runes or characters.
@@ -32,17 +38,18 @@ const maxTitleLenRunes = 1024
// Notifier implements a Notifier for Slack notifications.
type Notifier struct {
conf *config.SlackConfig
tmpl *template.Template
logger *slog.Logger
client *http.Client
retrier *notify.Retrier
conf *config.SlackConfig
tmpl *template.Template
logger *slog.Logger
client *http.Client
retrier *notify.Retrier
templater alertmanagertypes.Templater
postJSONFunc func(ctx context.Context, client *http.Client, url string, body io.Reader) (*http.Response, error)
}
// New returns a new Slack notification handler.
func New(c *config.SlackConfig, t *template.Template, l *slog.Logger, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) {
func New(c *config.SlackConfig, t *template.Template, l *slog.Logger, templater alertmanagertypes.Templater, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) {
client, err := notify.NewClientWithTracing(*c.HTTPConfig, Integration, httpOpts...)
if err != nil {
return nil, err
@@ -54,6 +61,7 @@ func New(c *config.SlackConfig, t *template.Template, l *slog.Logger, httpOpts .
logger: l,
client: client,
retrier: &notify.Retrier{},
templater: templater,
postJSONFunc: notify.PostJSON,
}, nil
}
@@ -81,9 +89,10 @@ type attachment struct {
Actions []config.SlackAction `json:"actions,omitempty"`
ImageURL string `json:"image_url,omitempty"`
ThumbURL string `json:"thumb_url,omitempty"`
Footer string `json:"footer"`
Footer string `json:"footer,omitempty"`
Color string `json:"color,omitempty"`
MrkdwnIn []string `json:"mrkdwn_in,omitempty"`
Blocks []any `json:"blocks,omitempty"`
}
// Notify implements the Notifier interface.
@@ -100,79 +109,15 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error)
data = notify.GetTemplateData(ctx, n.tmpl, as, logger)
tmplText = notify.TmplText(n.tmpl, data, &err)
)
var markdownIn []string
if len(n.conf.MrkdwnIn) == 0 {
markdownIn = []string{"fallback", "pretext", "text"}
} else {
markdownIn = n.conf.MrkdwnIn
attachments, err := n.prepareContent(ctx, as, tmplText)
if err != nil {
n.logger.ErrorContext(ctx, "failed to prepare notification content", errors.Attr(err))
return false, err
}
title, truncated := notify.TruncateInRunes(tmplText(n.conf.Title), maxTitleLenRunes)
if truncated {
logger.WarnContext(ctx, "Truncated title", slog.Int("max_runes", maxTitleLenRunes))
}
att := &attachment{
Title: title,
TitleLink: tmplText(n.conf.TitleLink),
Pretext: tmplText(n.conf.Pretext),
Text: tmplText(n.conf.Text),
Fallback: tmplText(n.conf.Fallback),
CallbackID: tmplText(n.conf.CallbackID),
ImageURL: tmplText(n.conf.ImageURL),
ThumbURL: tmplText(n.conf.ThumbURL),
Footer: tmplText(n.conf.Footer),
Color: tmplText(n.conf.Color),
MrkdwnIn: markdownIn,
}
numFields := len(n.conf.Fields)
if numFields > 0 {
fields := make([]config.SlackField, numFields)
for index, field := range n.conf.Fields {
// Check if short was defined for the field otherwise fallback to the global setting
var short bool
if field.Short != nil {
short = *field.Short
} else {
short = n.conf.ShortFields
}
// Rebuild the field by executing any templates and setting the new value for short
fields[index] = config.SlackField{
Title: tmplText(field.Title),
Value: tmplText(field.Value),
Short: &short,
}
}
att.Fields = fields
}
numActions := len(n.conf.Actions)
if numActions > 0 {
actions := make([]config.SlackAction, numActions)
for index, action := range n.conf.Actions {
slackAction := config.SlackAction{
Type: tmplText(action.Type),
Text: tmplText(action.Text),
URL: tmplText(action.URL),
Style: tmplText(action.Style),
Name: tmplText(action.Name),
Value: tmplText(action.Value),
}
if action.ConfirmField != nil {
slackAction.ConfirmField = &config.SlackConfirmationField{
Title: tmplText(action.ConfirmField.Title),
Text: tmplText(action.ConfirmField.Text),
OkText: tmplText(action.ConfirmField.OkText),
DismissText: tmplText(action.ConfirmField.DismissText),
}
}
actions[index] = slackAction
}
att.Actions = actions
if len(attachments) > 0 {
n.addFieldsAndActions(&attachments[0], tmplText)
}
req := &request{
@@ -182,7 +127,7 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error)
IconURL: tmplText(n.conf.IconURL),
LinkNames: n.conf.LinkNames,
Text: tmplText(n.conf.MessageText),
Attachments: []attachment{*att},
Attachments: attachments,
}
if err != nil {
return false, err
@@ -238,6 +183,150 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error)
return retry, nil
}
// prepareContent expands alert templates and returns the Slack attachment(s)
// ready to send. When alerts carry a custom body template, one title-only
// attachment plus one body attachment per alert is returned so that each alert
// can get its own firing/resolved color and per-alert action buttons.
func (n *Notifier) prepareContent(ctx context.Context, alerts []*types.Alert, tmplText func(string) string) ([]attachment, error) {
customTitle, customBody := alertmanagertemplate.ExtractTemplatesFromAnnotations(alerts)
result, err := n.templater.Expand(ctx, alertmanagertypes.ExpandRequest{
TitleTemplate: customTitle,
BodyTemplate: customBody,
DefaultTitleTemplate: n.conf.Title,
DefaultBodyTemplate: n.conf.Text,
}, alerts)
if err != nil {
return nil, err
}
title, truncated := notify.TruncateInRunes(result.Title, maxTitleLenRunes)
if truncated {
n.logger.WarnContext(ctx, "Truncated title", slog.Int("max_runes", maxTitleLenRunes))
}
if result.IsDefaultBody {
var markdownIn []string
if len(n.conf.MrkdwnIn) == 0 {
markdownIn = []string{"fallback", "pretext", "text"}
} else {
markdownIn = n.conf.MrkdwnIn
}
return []attachment{
{
Title: title,
TitleLink: tmplText(n.conf.TitleLink),
Pretext: tmplText(n.conf.Pretext),
Text: result.Body[0],
Fallback: tmplText(n.conf.Fallback),
CallbackID: tmplText(n.conf.CallbackID),
ImageURL: tmplText(n.conf.ImageURL),
ThumbURL: tmplText(n.conf.ThumbURL),
Footer: tmplText(n.conf.Footer),
Color: tmplText(n.conf.Color),
MrkdwnIn: markdownIn,
},
}, nil
}
// Custom template path: one title attachment + one attachment per
// non-empty alert body. result.Body is positionally aligned with alerts,
// so we index alerts[i] directly and skip empty entries.
attachments := make([]attachment, 0, 1+len(result.Body))
attachments = append(attachments, attachment{
Title: title,
TitleLink: tmplText(n.conf.TitleLink),
})
for i, body := range result.Body {
if body == "" || i >= len(alerts) {
continue
}
// Custom bodies are authored in markdown; render each non-empty body to
// Slack's mrkdwn flavour. Default bodies skip this because the Text
// template is already channel-ready.
rendered, renderErr := markdownrenderer.RenderSlackMrkdwn(body)
if renderErr != nil {
return nil, renderErr
}
color := colorRed
if alerts[i].Resolved() {
color = colorGreen
}
attachments = append(attachments, attachment{
Text: rendered,
Color: color,
MrkdwnIn: []string{"text"},
Actions: buildRelatedLinkActions(alerts[i]),
})
}
return attachments, nil
}
// buildRelatedLinkActions returns the "View Related Logs/Traces" action
// buttons for an alert, or nil when no related-link annotations are present.
func buildRelatedLinkActions(alert *types.Alert) []config.SlackAction {
var actions []config.SlackAction
if link := alert.Annotations[ruletypes.AnnotationRelatedLogs]; link != "" {
actions = append(actions, config.SlackAction{Type: "button", Text: "View Related Logs", URL: string(link)})
}
if link := alert.Annotations[ruletypes.AnnotationRelatedTraces]; link != "" {
actions = append(actions, config.SlackAction{Type: "button", Text: "View Related Traces", URL: string(link)})
}
return actions
}
// addFieldsAndActions populates fields and actions on the attachment from the Slack config.
func (n *Notifier) addFieldsAndActions(att *attachment, tmplText func(string) string) {
numFields := len(n.conf.Fields)
if numFields > 0 {
fields := make([]config.SlackField, numFields)
for index, field := range n.conf.Fields {
var short bool
if field.Short != nil {
short = *field.Short
} else {
short = n.conf.ShortFields
}
fields[index] = config.SlackField{
Title: tmplText(field.Title),
Value: tmplText(field.Value),
Short: &short,
}
}
att.Fields = fields
}
numActions := len(n.conf.Actions)
if numActions > 0 {
actions := make([]config.SlackAction, numActions)
for index, action := range n.conf.Actions {
slackAction := config.SlackAction{
Type: tmplText(action.Type),
Text: tmplText(action.Text),
URL: tmplText(action.URL),
Style: tmplText(action.Style),
Name: tmplText(action.Name),
Value: tmplText(action.Value),
}
if action.ConfirmField != nil {
slackAction.ConfirmField = &config.SlackConfirmationField{
Title: tmplText(action.ConfirmField.Title),
Text: tmplText(action.ConfirmField.Text),
OkText: tmplText(action.ConfirmField.OkText),
DismissText: tmplText(action.ConfirmField.DismissText),
}
}
actions[index] = slackAction
}
att.Actions = actions
}
}
// checkResponseError parses out the error message from Slack API response.
func checkResponseError(resp *http.Response) (bool, error) {
body, err := io.ReadAll(resp.Body)

View File

@@ -17,6 +17,9 @@ import (
"testing"
"time"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
commoncfg "github.com/prometheus/common/config"
"github.com/prometheus/common/model"
"github.com/prometheus/common/promslog"
@@ -29,13 +32,19 @@ import (
"github.com/prometheus/alertmanager/types"
)
func newTestTemplater(tmpl *template.Template) alertmanagertypes.Templater {
return alertmanagertemplate.New(tmpl, slog.New(slog.DiscardHandler))
}
func TestSlackRetry(t *testing.T) {
tmpl := test.CreateTmpl(t)
notifier, err := New(
&config.SlackConfig{
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
test.CreateTmpl(t),
tmpl,
promslog.NewNopLogger(),
newTestTemplater(tmpl),
)
require.NoError(t, err)
@@ -49,13 +58,15 @@ func TestSlackRedactedURL(t *testing.T) {
ctx, u, fn := test.GetContextWithCancelingURL()
defer fn()
tmpl := test.CreateTmpl(t)
notifier, err := New(
&config.SlackConfig{
APIURL: &config.SecretURL{URL: u},
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
test.CreateTmpl(t),
tmpl,
promslog.NewNopLogger(),
newTestTemplater(tmpl),
)
require.NoError(t, err)
@@ -71,13 +82,15 @@ func TestGettingSlackURLFromFile(t *testing.T) {
_, err = f.WriteString(u.String())
require.NoError(t, err, "writing to temp file failed")
tmpl := test.CreateTmpl(t)
notifier, err := New(
&config.SlackConfig{
APIURLFile: f.Name(),
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
test.CreateTmpl(t),
tmpl,
promslog.NewNopLogger(),
newTestTemplater(tmpl),
)
require.NoError(t, err)
@@ -93,13 +106,15 @@ func TestTrimmingSlackURLFromFile(t *testing.T) {
_, err = f.WriteString(u.String() + "\n\n")
require.NoError(t, err, "writing to temp file failed")
tmpl := test.CreateTmpl(t)
notifier, err := New(
&config.SlackConfig{
APIURLFile: f.Name(),
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
test.CreateTmpl(t),
tmpl,
promslog.NewNopLogger(),
newTestTemplater(tmpl),
)
require.NoError(t, err)
@@ -184,6 +199,7 @@ func TestNotifier_Notify_WithReason(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
apiurl, _ := url.Parse("https://slack.com/post.Message")
tmpl := test.CreateTmpl(t)
notifier, err := New(
&config.SlackConfig{
NotifierConfig: config.NotifierConfig{},
@@ -191,8 +207,9 @@ func TestNotifier_Notify_WithReason(t *testing.T) {
APIURL: &config.SecretURL{URL: apiurl},
Channel: "channelname",
},
test.CreateTmpl(t),
tmpl,
promslog.NewNopLogger(),
newTestTemplater(tmpl),
)
require.NoError(t, err)
@@ -242,6 +259,7 @@ func TestSlackTimeout(t *testing.T) {
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
u, _ := url.Parse("https://slack.com/post.Message")
tmpl := test.CreateTmpl(t)
notifier, err := New(
&config.SlackConfig{
NotifierConfig: config.NotifierConfig{},
@@ -250,8 +268,9 @@ func TestSlackTimeout(t *testing.T) {
Channel: "channelname",
Timeout: tt.timeout,
},
test.CreateTmpl(t),
tmpl,
promslog.NewNopLogger(),
newTestTemplater(tmpl),
)
require.NoError(t, err)
notifier.postJSONFunc = func(ctx context.Context, client *http.Client, url string, body io.Reader) (*http.Response, error) {
@@ -282,6 +301,225 @@ func TestSlackTimeout(t *testing.T) {
}
}
// setupTestContext creates a context with group key, receiver name, and group labels
// required by the notification processor.
func setupTestContext() context.Context {
ctx := context.Background()
ctx = notify.WithGroupKey(ctx, "test-group")
ctx = notify.WithReceiverName(ctx, "slack")
ctx = notify.WithGroupLabels(ctx, model.LabelSet{
"alertname": "TestAlert",
"severity": "critical",
})
return ctx
}
func TestPrepareContent(t *testing.T) {
t.Run("default template uses go text template config for title and body", func(t *testing.T) {
// When alerts have no custom annotation templates (title_template / body_template),
tmpl := test.CreateTmpl(t)
templater := newTestTemplater(tmpl)
notifier := &Notifier{
conf: &config.SlackConfig{
Title: `{{ .CommonLabels.alertname }} ({{ .Status | toUpper }})`,
Text: `{{ range .Alerts }}Alert: {{ .Labels.alertname }} - severity {{ .Labels.severity }}{{ end }}`,
Color: `{{ if eq .Status "firing" }}danger{{ else }}good{{ end }}`,
TitleLink: "https://alertmanager.signoz.com",
},
tmpl: tmpl,
logger: slog.New(slog.DiscardHandler),
templater: templater,
}
ctx := setupTestContext()
alerts := []*types.Alert{
{Alert: model.Alert{
Labels: model.LabelSet{ruletypes.LabelAlertName: "HighCPU", ruletypes.LabelSeverityName: "critical"},
StartsAt: time.Now(),
EndsAt: time.Now().Add(time.Hour),
}},
}
// Build tmplText the same way Notify does
var err error
data := notify.GetTemplateData(ctx, tmpl, alerts, slog.New(slog.DiscardHandler))
tmplText := notify.TmplText(tmpl, data, &err)
atts, attErr := notifier.prepareContent(ctx, alerts, tmplText)
require.NoError(t, attErr)
require.NoError(t, err)
require.Len(t, atts, 1)
require.Equal(t, "HighCPU (FIRING)", atts[0].Title)
require.Equal(t, "Alert: HighCPU - severity critical", atts[0].Text)
// Color is templated — firing alert should be "danger"
require.Equal(t, "danger", atts[0].Color)
// No BlockKit blocks for default template
require.Nil(t, atts[0].Blocks)
// Default markdownIn when config has none
require.Equal(t, []string{"fallback", "pretext", "text"}, atts[0].MrkdwnIn)
})
t.Run("custom template produces 1+N attachments with per-alert color", func(t *testing.T) {
// When alerts carry custom $variable annotation templates (title_template / body_template)
tmpl := test.CreateTmpl(t)
templater := newTestTemplater(tmpl)
notifier := &Notifier{
conf: &config.SlackConfig{
Title: "default title fallback",
Text: "default text fallback",
TitleLink: "https://alertmanager.signoz.com",
},
tmpl: tmpl,
logger: slog.New(slog.DiscardHandler),
templater: templater,
}
tmplText := func(s string) string { return s }
bodyTemplate := `## $rule.name
**Service:** *$labels.service*
**Instance:** *$labels.instance*
**Region:** *$labels.region*
**Method:** *$labels.http_method*
---
| Metric | Value |
|--------|-------|
| **Current** | *$value* |
| **Threshold** | *$threshold.value* |
**Status:** $alert.status | **Severity:** $labels.severity`
titleTemplate := "[$alert.status] $rule.name — $labels.service"
ctx := setupTestContext()
firingAlert := &types.Alert{
Alert: model.Alert{
Labels: model.LabelSet{ruletypes.LabelAlertName: "HighCPU", ruletypes.LabelSeverityName: "critical", "service": "api-server", "instance": "i-0abc123", "region": "us-east-1", "http_method": "GET"},
StartsAt: time.Now(),
EndsAt: time.Now().Add(time.Hour),
Annotations: model.LabelSet{
ruletypes.AnnotationTitleTemplate: model.LabelValue(titleTemplate),
ruletypes.AnnotationBodyTemplate: model.LabelValue(bodyTemplate),
"value": "100",
"threshold.value": "200",
},
},
}
resolvedAlert := &types.Alert{
Alert: model.Alert{
Labels: model.LabelSet{ruletypes.LabelAlertName: "HighCPU", ruletypes.LabelSeverityName: "critical", "service": "api-server", "instance": "i-0abc123", "region": "us-east-1", "http_method": "GET"},
StartsAt: time.Now().Add(-2 * time.Hour),
EndsAt: time.Now().Add(-time.Hour),
Annotations: model.LabelSet{
ruletypes.AnnotationTitleTemplate: model.LabelValue(titleTemplate),
ruletypes.AnnotationBodyTemplate: model.LabelValue(bodyTemplate),
"value": "50",
"threshold.value": "200",
},
},
}
atts, err := notifier.prepareContent(ctx, []*types.Alert{firingAlert, resolvedAlert}, tmplText)
require.NoError(t, err)
// 1 title attachment + 2 body attachments (one per alert)
require.Len(t, atts, 3)
// First attachment: title-only, no color, no blocks
require.Equal(t, "[firing] HighCPU — api-server", atts[0].Title)
require.Empty(t, atts[0].Color)
require.Nil(t, atts[0].Blocks)
require.Equal(t, "https://alertmanager.signoz.com", atts[0].TitleLink)
expectedFiringBody := "*HighCPU*\n\n" +
"*Service:* _api-server_\n*Instance:* _i-0abc123_\n*Region:* _us-east-1_\n*Method:* _GET_\n\n" +
"---\n\n" +
"```\nMetric | Value\n----------|------\nCurrent | 100 \nThreshold | 200 \n```\n\n" +
"*Status:* firing | *Severity:* critical\n\n"
expectedResolvedBody := "*HighCPU*\n\n" +
"*Service:* _api-server_\n*Instance:* _i-0abc123_\n*Region:* _us-east-1_\n*Method:* _GET_\n\n" +
"---\n\n" +
"```\nMetric | Value\n----------|------\nCurrent | 50 \nThreshold | 200 \n```\n\n" +
"*Status:* resolved | *Severity:* critical\n\n"
// Second attachment: firing alert body rendered as slack mrkdwn text, red color
require.Nil(t, atts[1].Blocks)
require.Equal(t, "#FF0000", atts[1].Color)
require.Equal(t, []string{"text"}, atts[1].MrkdwnIn)
require.Equal(t, expectedFiringBody, atts[1].Text)
// Third attachment: resolved alert body rendered as slack mrkdwn text, green color
require.Nil(t, atts[2].Blocks)
require.Equal(t, "#00FF00", atts[2].Color)
require.Equal(t, []string{"text"}, atts[2].MrkdwnIn)
require.Equal(t, expectedResolvedBody, atts[2].Text)
})
t.Run("default template with fields and actions", func(t *testing.T) {
// Verifies that addFieldsAndActions (called from Notify after prepareContent)
// correctly populates fields and actions on the attachment from config.
tmpl := test.CreateTmpl(t)
templater := newTestTemplater(tmpl)
short := true
notifier := &Notifier{
conf: &config.SlackConfig{
Title: `{{ .CommonLabels.alertname }}`,
Text: "alert text",
Color: "warning",
Fields: []*config.SlackField{
{Title: "Severity", Value: "critical", Short: &short},
{Title: "Service", Value: "api-server", Short: &short},
},
Actions: []*config.SlackAction{
{Type: "button", Text: "View Alert", URL: "https://alertmanager.signoz.com"},
},
TitleLink: "https://alertmanager.signoz.com",
},
tmpl: tmpl,
logger: slog.New(slog.DiscardHandler),
templater: templater,
}
tmplText := func(s string) string { return s }
ctx := setupTestContext()
alerts := []*types.Alert{
{Alert: model.Alert{
Labels: model.LabelSet{ruletypes.LabelAlertName: "TestAlert"},
StartsAt: time.Now(),
EndsAt: time.Now().Add(time.Hour),
}},
}
atts, err := notifier.prepareContent(ctx, alerts, tmplText)
require.NoError(t, err)
require.Len(t, atts, 1)
// prepareContent does not populate fields/actions — that's done by
// addFieldsAndActions which is called from Notify.
require.Nil(t, atts[0].Fields)
require.Nil(t, atts[0].Actions)
// Simulate what Notify does after prepareContent
notifier.addFieldsAndActions(&atts[0], tmplText)
// Verify fields
require.Len(t, atts[0].Fields, 2)
require.Equal(t, "Severity", atts[0].Fields[0].Title)
require.Equal(t, "critical", atts[0].Fields[0].Value)
require.True(t, *atts[0].Fields[0].Short)
require.Equal(t, "Service", atts[0].Fields[1].Title)
require.Equal(t, "api-server", atts[0].Fields[1].Value)
// Verify actions
require.Len(t, atts[0].Actions, 1)
require.Equal(t, "button", atts[0].Actions[0].Type)
require.Equal(t, "View Alert", atts[0].Actions[0].Text)
require.Equal(t, "https://alertmanager.signoz.com", atts[0].Actions[0].URL)
})
}
func TestSlackMessageField(t *testing.T) {
// 1. Setup a fake Slack server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -329,7 +567,7 @@ func TestSlackMessageField(t *testing.T) {
tmpl.ExternalURL = u
logger := slog.New(slog.DiscardHandler)
notifier, err := New(conf, tmpl, logger)
notifier, err := New(conf, tmpl, logger, newTestTemplater(tmpl))
if err != nil {
t.Fatal(err)
}

View File

@@ -14,6 +14,7 @@ import (
"strings"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
commoncfg "github.com/prometheus/common/config"
"github.com/prometheus/alertmanager/config"
@@ -28,15 +29,16 @@ const (
// Notifier implements a Notifier for generic webhooks.
type Notifier struct {
conf *config.WebhookConfig
tmpl *template.Template
logger *slog.Logger
client *http.Client
retrier *notify.Retrier
conf *config.WebhookConfig
tmpl *template.Template
logger *slog.Logger
client *http.Client
retrier *notify.Retrier
templater alertmanagertypes.Templater
}
// New returns a new Webhook.
func New(conf *config.WebhookConfig, t *template.Template, l *slog.Logger, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) {
func New(conf *config.WebhookConfig, t *template.Template, l *slog.Logger, templater alertmanagertypes.Templater, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) {
client, err := notify.NewClientWithTracing(*conf.HTTPConfig, Integration, httpOpts...)
if err != nil {
return nil, err
@@ -48,7 +50,8 @@ func New(conf *config.WebhookConfig, t *template.Template, l *slog.Logger, httpO
client: client,
// Webhooks are assumed to respond with 2xx response codes on a successful
// request and 5xx response codes are assumed to be recoverable.
retrier: &notify.Retrier{},
retrier: &notify.Retrier{},
templater: templater,
}, nil
}

View File

@@ -9,6 +9,7 @@ import (
"context"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
@@ -20,6 +21,7 @@ import (
"github.com/prometheus/common/promslog"
"github.com/stretchr/testify/require"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
"github.com/prometheus/alertmanager/config"
"github.com/prometheus/alertmanager/notify"
"github.com/prometheus/alertmanager/notify/test"
@@ -27,13 +29,15 @@ import (
)
func TestWebhookRetry(t *testing.T) {
tmpl := test.CreateTmpl(t)
notifier, err := New(
&config.WebhookConfig{
URL: config.SecretTemplateURL("http://example.com"),
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
test.CreateTmpl(t),
tmpl,
promslog.NewNopLogger(),
alertmanagertemplate.New(tmpl, slog.Default()),
)
if err != nil {
require.NoError(t, err)
@@ -96,13 +100,16 @@ func TestWebhookRedactedURL(t *testing.T) {
defer fn()
secret := "secret"
tmpl := test.CreateTmpl(t)
templater := alertmanagertemplate.New(tmpl, slog.Default())
notifier, err := New(
&config.WebhookConfig{
URL: config.SecretTemplateURL(u.String()),
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
test.CreateTmpl(t),
tmpl,
promslog.NewNopLogger(),
templater,
)
require.NoError(t, err)
@@ -118,13 +125,15 @@ func TestWebhookReadingURLFromFile(t *testing.T) {
_, err = f.WriteString(u.String() + "\n")
require.NoError(t, err, "writing to temp file failed")
tmpl := test.CreateTmpl(t)
notifier, err := New(
&config.WebhookConfig{
URLFile: f.Name(),
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
test.CreateTmpl(t),
tmpl,
promslog.NewNopLogger(),
alertmanagertemplate.New(tmpl, slog.Default()),
)
require.NoError(t, err)
@@ -178,13 +187,15 @@ func TestWebhookURLTemplating(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
calledURL = "" // Reset for each test
tmpl := test.CreateTmpl(t)
notifier, err := New(
&config.WebhookConfig{
URL: config.SecretTemplateURL(tc.url),
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
test.CreateTmpl(t),
tmpl,
promslog.NewNopLogger(),
alertmanagertemplate.New(tmpl, slog.Default()),
)
require.NoError(t, err)

View File

@@ -28,6 +28,13 @@ type Config struct {
// Configuration for the notification log.
NFLog NFLogConfig `mapstructure:"nflog"`
// Templates is the list of globs from which SigNoz's alertmanager notification
// templates are loaded (e.g. the email.signoz.html layout). This mirrors the
// upstream alertmanager `templates` config option (https://github.com/prometheus/alertmanager/blob/3b06b97af4d146e141af92885a185891eb79a5b0/config/config.go#L412).
// The upstream default templates (default.tmpl, email.tmpl) are always loaded
// from the embedded alertmanager assets, so only SigNoz's own templates are listed here.
Templates []string `mapstructure:"templates"`
}
type AlertsConfig struct {
@@ -100,5 +107,6 @@ func NewConfig() Config {
MaintenanceInterval: 15 * time.Minute,
Retention: 120 * time.Hour,
},
Templates: []string{"/root/templates/alertmanager/*.gotmpl"},
}
}

View File

@@ -10,6 +10,7 @@ import (
"github.com/prometheus/alertmanager/types"
"golang.org/x/sync/errgroup"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/prometheus/alertmanager/dispatch"
"github.com/prometheus/alertmanager/featurecontrol"
"github.com/prometheus/alertmanager/inhibit"
@@ -23,8 +24,8 @@ import (
"github.com/prometheus/common/model"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
)
@@ -65,6 +66,7 @@ type Server struct {
muter *MaintenanceMuter
marker *types.MemMarker
tmpl *template.Template
templater alertmanagertypes.Templater
wg sync.WaitGroup
stopc chan struct{}
notificationManager nfmanager.NotificationManager
@@ -242,13 +244,21 @@ func (server *Server) SetConfig(ctx context.Context, alertmanagerConfig *alertma
config := alertmanagerConfig.AlertmanagerConfig()
var err error
server.tmpl, err = alertmanagertypes.FromGlobs(config.Templates)
// Load SigNoz's alertmanager notification templates from the configured
// globs. The upstream default templates (default.tmpl, email.tmpl) are
// always loaded from the embedded alertmanager assets inside FromGlobs, so
// only SigNoz's own templates (e.g. the email.signoz.html layout) are listed
// here. The upstream config.Templates field is not used: SigNoz never
// populates it (there is no per-org template configuration).
server.tmpl, err = alertmanagertypes.FromGlobs(server.srvConfig.Templates)
if err != nil {
return err
}
server.tmpl.ExternalURL = server.srvConfig.ExternalURL
server.templater = alertmanagertemplate.New(server.tmpl, server.logger)
// Build the routing tree and record which receivers are used.
routes := dispatch.NewRoute(config.Route, nil)
activeReceivers := make(map[string]struct{})
@@ -265,7 +275,7 @@ func (server *Server) SetConfig(ctx context.Context, alertmanagerConfig *alertma
server.logger.InfoContext(ctx, "skipping creation of receiver not referenced by any route", slog.String("receiver", rcv.Name))
continue
}
integrations, err := alertmanagernotify.NewReceiverIntegrations(rcv, server.tmpl, server.logger)
integrations, err := alertmanagernotify.NewReceiverIntegrations(rcv, server.tmpl, server.logger, server.templater)
if err != nil {
return err
}
@@ -342,7 +352,7 @@ func (server *Server) SetConfig(ctx context.Context, alertmanagerConfig *alertma
func (server *Server) TestReceiver(ctx context.Context, receiver alertmanagertypes.Receiver) error {
testAlert := alertmanagertypes.NewTestAlert(receiver, time.Now(), time.Now())
return alertmanagertypes.TestReceiver(ctx, receiver, alertmanagernotify.NewReceiverIntegrations, server.alertmanagerConfig, server.tmpl, server.logger, testAlert.Labels, testAlert)
return alertmanagertypes.TestReceiver(ctx, receiver, alertmanagernotify.NewReceiverIntegrations, server.alertmanagerConfig, server.tmpl, server.logger, server.templater, testAlert.Labels, testAlert)
}
func (server *Server) TestAlert(ctx context.Context, receiversMap map[*alertmanagertypes.PostableAlert][]string, config *alertmanagertypes.NotificationConfig) error {
@@ -425,6 +435,7 @@ func (server *Server) TestAlert(ctx context.Context, receiversMap map[*alertmana
server.alertmanagerConfig,
server.tmpl,
server.logger,
server.templater,
group.groupLabels,
group.alerts...,
)

View File

@@ -15,13 +15,6 @@ import (
"github.com/prometheus/common/model"
)
// Templater expands user-authored title and body templates against a group
// of alerts and returns channel-ready strings along with the aggregate data
// a caller might reuse (e.g. to render an email layout around the body).
type Templater interface {
Expand(ctx context.Context, req alertmanagertypes.ExpandRequest, alerts []*types.Alert) (*alertmanagertypes.ExpandResult, error)
}
type templater struct {
tmpl *template.Template
logger *slog.Logger
@@ -29,7 +22,7 @@ type templater struct {
// New returns a Templater bound to the given Prometheus alertmanager
// template and logger.
func New(tmpl *template.Template, logger *slog.Logger) Templater {
func New(tmpl *template.Template, logger *slog.Logger) alertmanagertypes.Templater {
return &templater{tmpl: tmpl, logger: logger}
}
@@ -137,6 +130,9 @@ func (at *templater) expandTitle(
}
// expandBody expands the body template for each individual alert. Returns nil if the template is empty.
// Non-nil results are positionally aligned with ntd.Alerts: sb[i] corresponds to alerts[i], and
// entries for alerts whose template expands to empty are kept as "" so callers can index per-alert
// metadata (related links, firing/resolved color) by the same index.
func (at *templater) expandBody(
bodyTemplate string,
ntd *alertmanagertypes.NotificationTemplateData,
@@ -144,7 +140,7 @@ func (at *templater) expandBody(
if bodyTemplate == "" {
return nil, nil, nil
}
var sb []string
sb := make([]string, len(ntd.Alerts))
missingVars := make(map[string]bool)
for i := range ntd.Alerts {
processRes, err := preProcessTemplateAndData(bodyTemplate, &ntd.Alerts[i])
@@ -155,13 +151,10 @@ func (at *templater) expandBody(
if err != nil {
return nil, nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "failed to execute custom body template: %s", err.Error())
}
// add unknown variables and templated text to the result
for k := range processRes.UnknownVars {
missingVars[k] = true
}
if strings.TrimSpace(part) != "" {
sb = append(sb, strings.TrimSpace(part))
}
sb[i] = strings.TrimSpace(part)
}
return sb, missingVars, nil
}
@@ -189,17 +182,20 @@ func (at *templater) buildNotificationTemplateData(
externalURL = at.tmpl.ExternalURL.String()
}
commonAnnotations := extractCommonKV(alerts, func(a *types.Alert) model.LabelSet { return a.Annotations })
// Raw (including private `_*`) kv first so buildRuleInfo can read the
// private rule annotations. The filtered copies are what ends up
// on the template-visible surfaces.
rawCommonAnnotations := extractCommonKV(alerts, func(a *types.Alert) model.LabelSet { return a.Annotations })
commonLabels := extractCommonKV(alerts, func(a *types.Alert) model.LabelSet { return a.Labels })
// aggregate labels and annotations from all alerts
labels := aggregateKV(alerts, func(a *types.Alert) model.LabelSet { return a.Labels })
annotations := aggregateKV(alerts, func(a *types.Alert) model.LabelSet { return a.Annotations })
// Strip private annotations from surfaces visible to templates or
// notifications; the structured fields on AlertInfo/RuleInfo already hold
// anything a template needs from them.
commonAnnotations = alertmanagertypes.FilterPublicAnnotations(commonAnnotations)
// Strip private annotations from template-visible surfaces; the structured
// fields on AlertInfo/RuleInfo already hold anything a template needs from
// them.
commonAnnotations := alertmanagertypes.FilterPublicAnnotations(rawCommonAnnotations)
annotations = alertmanagertypes.FilterPublicAnnotations(annotations)
// build the alert data slice
@@ -233,7 +229,7 @@ func (at *templater) buildNotificationTemplateData(
TotalFiring: firing,
TotalResolved: resolved,
},
Rule: buildRuleInfo(commonLabels, commonAnnotations),
Rule: buildRuleInfo(commonLabels, rawCommonAnnotations),
GroupLabels: gl,
CommonLabels: commonLabels,
CommonAnnotations: commonAnnotations,

View File

@@ -19,7 +19,7 @@ import (
// testSetup returns an AlertTemplater and a context pre-populated with group key,
// receiver name, and group labels for use in tests.
func testSetup(t *testing.T) (Templater, context.Context) {
func testSetup(t *testing.T) (alertmanagertypes.Templater, context.Context) {
t.Helper()
tmpl := test.CreateTmpl(t)
ctx := context.Background()

View File

@@ -48,24 +48,5 @@ func (provider *provider) addTraceDetailRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v1/traces/{traceID}/aggregations", handler.New(
provider.authzMiddleware.ViewAccess(provider.traceDetailHandler.GetTraceAggregations),
handler.OpenAPIDef{
ID: "GetTraceAggregations",
Tags: []string{"tracedetail"},
Summary: "Get aggregations for a trace",
Description: "Computes span aggregations grouped by requested field.",
Request: new(spantypes.PostableTraceAggregations),
RequestContentType: "application/json",
Response: new(spantypes.GettableTraceAggregations),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
},
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
return nil
}

View File

@@ -65,7 +65,7 @@ func newConfig() factory.Config {
return &Config{
Enabled: false,
Templates: Templates{
Directory: "/root/templates",
Directory: "/root/templates/email",
Format: Format{
Header: Header{
Enabled: false,

View File

@@ -59,24 +59,3 @@ func (h *handler) GetWaterfallV4(rw http.ResponseWriter, r *http.Request) {
render.Success(rw, http.StatusOK, result)
}
func (h *handler) GetTraceAggregations(rw http.ResponseWriter, r *http.Request) {
req := new(spantypes.PostableTraceAggregations)
if err := binding.JSON.BindBody(r.Body, req); err != nil {
render.Error(rw, err)
return
}
if err := req.Validate(); err != nil {
render.Error(rw, err)
return
}
result, err := h.module.GetTraceAggregations(r.Context(), mux.Vars(r)["traceID"], req)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, result)
}

View File

@@ -105,49 +105,6 @@ func (m *module) getFullWaterfall(ctx context.Context, traceID string, summary *
return spantypes.NewGettableWaterfallTrace(waterfallTrace, selectedSpans, nil, true, nil), nil
}
func (m *module) GetTraceAggregations(ctx context.Context, traceID string, req *spantypes.PostableTraceAggregations) (*spantypes.GettableTraceAggregations, error) {
summary, err := m.store.GetTraceSummary(ctx, traceID)
if err != nil {
return nil, err
}
traceDurationNs := uint64(summary.End.UnixNano()) - uint64(summary.Start.UnixNano())
results := make([]spantypes.SpanAggregationResult, 0, len(req.Aggregations))
for _, agg := range req.Aggregations {
result := spantypes.SpanAggregationResult{Field: agg.Field, Aggregation: agg.Aggregation}
switch agg.Aggregation {
case spantypes.SpanAggregationSpanCount:
result.Value, err = m.store.GetSpanCountByField(ctx, traceID, summary, agg.Field)
if err != nil {
return nil, err
}
case spantypes.SpanAggregationDuration:
durationNs, err2 := m.store.GetSpanDurationByField(ctx, traceID, summary, agg.Field)
if err2 != nil {
return nil, err2
}
result.Value = make(map[string]uint64, len(durationNs))
for k, ns := range durationNs {
result.Value[k] = ns / 1_000_000
}
case spantypes.SpanAggregationExecutionTimePercentage:
durationNs, err2 := m.store.GetSpanDurationByField(ctx, traceID, summary, agg.Field)
if err2 != nil {
return nil, err2
}
result.Value = make(map[string]uint64, len(durationNs))
if traceDurationNs > 0 {
for k, ns := range durationNs {
result.Value[k] = ns * 100 / traceDurationNs
}
}
}
results = append(results, result)
}
return &spantypes.GettableTraceAggregations{Aggregations: results}, nil
}
// getWindowedWaterfall builds the waterfall tree with minimal data and then returns only a window of full spans.
func (m *module) getWindowedWaterfall(ctx context.Context, traceID, selectedSpanID string, uncollapsedSpans []string, start, end time.Time) (*spantypes.GettableWaterfallTrace, error) {
// Step 1: minimal fetch → build full tree → select visible window

View File

@@ -11,7 +11,6 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types/spantypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
const colServiceName = `resource_string_service$$$$name` // $ gets escaped so $$$$ converts to $$.
@@ -98,14 +97,6 @@ func (s *traceStore) GetMinimalSpans(ctx context.Context, traceID string, start,
return spans, nil
}
func (s *traceStore) GetSpanCountByField(ctx context.Context, traceID string, summary *spantypes.TraceSummary, fieldKey telemetrytypes.TelemetryFieldKey) (map[string]uint64, error) {
return nil, nil
}
func (s *traceStore) GetSpanDurationByField(ctx context.Context, traceID string, summary *spantypes.TraceSummary, fieldKey telemetrytypes.TelemetryFieldKey) (map[string]uint64, error) {
return nil, nil
}
func (s *traceStore) GetTraceSpansByIDs(ctx context.Context, traceID string, start, end time.Time, spanIDs []string) ([]spantypes.StorableSpan, error) {
if len(spanIDs) == 0 {
return []spantypes.StorableSpan{}, nil

View File

@@ -11,12 +11,10 @@ import (
type Handler interface {
GetWaterfall(http.ResponseWriter, *http.Request)
GetWaterfallV4(http.ResponseWriter, *http.Request)
GetTraceAggregations(http.ResponseWriter, *http.Request)
}
// Module defines the business logic for trace detail operations.
type Module interface {
GetWaterfall(ctx context.Context, traceID string, req *spantypes.PostableWaterfall) (*spantypes.GettableWaterfallTrace, error)
GetWaterfallV4(ctx context.Context, traceID string, selectedSpanID string, uncollapsedSpans []string, selectAllLimit uint) (*spantypes.GettableWaterfallTrace, error)
GetTraceAggregations(ctx context.Context, traceID string, req *spantypes.PostableTraceAggregations) (*spantypes.GettableTraceAggregations, error)
}

View File

@@ -3,7 +3,6 @@ package impluser
import (
"context"
"log/slog"
"slices"
"strings"
"time"
@@ -21,7 +20,6 @@ import (
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/types/emailtypes"
"github.com/SigNoz/signoz/pkg/types/integrationtypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
@@ -371,10 +369,6 @@ func (module *setter) DeleteUser(ctx context.Context, orgID valuer.UUID, id stri
return errors.WithAdditionalf(err, "cannot delete already deleted user")
}
if slices.Contains(integrationtypes.AllIntegrationUserEmails, integrationtypes.IntegrationUserEmail(user.Email.String())) {
return errors.New(errors.TypeForbidden, errors.CodeForbidden, "integration user cannot be deleted")
}
deleter, err := module.store.GetUser(ctx, valuer.MustNewUUID(deletedBy))
if err != nil {
return err

View File

@@ -19,6 +19,8 @@ import (
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
const traceOutsideRangeWarn = "Query %s references a trace_id that exists between %s and %s (UTC) but lies outside the selected time range; adjust the time range to see results"
type builderQuery[T any] struct {
logger *slog.Logger
telemetryStore telemetrystore.TelemetryStore
@@ -199,7 +201,21 @@ func (q *builderQuery[T]) Execute(ctx context.Context) (*qbtypes.Result, error)
return q.executeWindowList(ctx)
}
stmt, err := q.stmtBuilder.Build(ctx, q.fromMS, q.toMS, q.kind, q.spec, q.variables)
fromMS, toMS := q.fromMS, q.toMS
if q.spec.Signal == telemetrytypes.SignalTraces || q.spec.Signal == telemetrytypes.SignalLogs {
var overlap bool
var warning string
fromMS, toMS, overlap, warning = q.narrowWindowByTraceID(ctx, fromMS, toMS)
if !overlap {
res := emptyResultFor(q.kind, q.spec.Name)
if warning != "" {
res.Warnings = []string{warning}
}
return res, nil
}
}
stmt, err := q.stmtBuilder.Build(ctx, fromMS, toMS, q.kind, q.spec, q.variables)
if err != nil {
return nil, err
}
@@ -215,6 +231,88 @@ func (q *builderQuery[T]) Execute(ctx context.Context) (*qbtypes.Result, error)
return result, nil
}
// narrowWindowByTraceID inspects the filter for trace_id predicates and clamps
// [fromMS,toMS] to the time range stored in signoz_traces.distributed_trace_summary.
// Returns the (possibly narrowed) window, overlap=false when the trace lies
// completely outside the query window (callers should short-circuit), and a
// warning string the caller should attach to the empty result when the trace
// exists but is outside the selected window.
//
// When the trace_id is not present in trace_summary the behaviour differs by
// signal:
// - traces: trace_summary is derived from the spans table, so a missing row
// means no spans exist for that trace_id; we short-circuit to empty.
// - logs: logs can carry a trace_id even when traces are not ingested at all
// (e.g. traces disabled). We must not short-circuit; instead leave the
// window untouched and let the query run.
func (q *builderQuery[T]) narrowWindowByTraceID(ctx context.Context, fromMS, toMS uint64) (uint64, uint64, bool, string) {
if q.spec.Filter == nil || q.spec.Filter.Expression == "" {
return fromMS, toMS, true, ""
}
traceIDs, found := telemetrytraces.ExtractTraceIDsFromFilter(q.spec.Filter.Expression)
if !found || len(traceIDs) == 0 {
return fromMS, toMS, true, ""
}
finder := telemetrytraces.NewTraceTimeRangeFinder(q.telemetryStore)
traceStart, traceEnd, exists, err := finder.GetTraceTimeRangeMulti(ctx, traceIDs)
if err != nil {
return fromMS, toMS, true, ""
}
if !exists {
if q.spec.Signal == telemetrytypes.SignalTraces {
q.logger.DebugContext(ctx, "trace_id not found in trace_summary; short-circuiting traces query to empty",
slog.Any("trace_ids", traceIDs))
return fromMS, toMS, false, ""
}
q.logger.DebugContext(ctx, "trace_id not found in trace_summary; leaving time range untouched for logs",
slog.Any("trace_ids", traceIDs))
return fromMS, toMS, true, ""
}
traceStartMS := uint64(traceStart) / 1_000_000
traceEndMS := uint64(traceEnd) / 1_000_000
if traceStartMS == 0 || traceEndMS == 0 {
return fromMS, toMS, true, ""
}
if traceStartMS > toMS || traceEndMS < fromMS {
traceStartUTC := time.UnixMilli(int64(traceStartMS)).UTC().Format(time.RFC3339)
traceEndUTC := time.UnixMilli(int64(traceEndMS)).UTC().Format(time.RFC3339)
return fromMS, toMS, false, fmt.Sprintf(traceOutsideRangeWarn, q.spec.Name, traceStartUTC, traceEndUTC)
}
if traceStartMS > fromMS {
fromMS = traceStartMS
}
if traceEndMS < toMS {
toMS = traceEndMS
}
q.logger.DebugContext(ctx, "optimized time range using trace_id lookup",
slog.String("signal", q.spec.Signal.StringValue()),
slog.Any("trace_ids", traceIDs),
slog.Uint64("start", fromMS),
slog.Uint64("end", toMS))
return fromMS, toMS, true, ""
}
// emptyResultFor returns an empty result payload appropriate for the given kind.
func emptyResultFor(kind qbtypes.RequestType, queryName string) *qbtypes.Result {
var value any
switch kind {
case qbtypes.RequestTypeTimeSeries:
value = &qbtypes.TimeSeriesData{QueryName: queryName}
case qbtypes.RequestTypeScalar:
value = &qbtypes.ScalarData{QueryName: queryName}
default:
value = &qbtypes.RawData{QueryName: queryName}
}
return &qbtypes.Result{
Type: kind,
Value: value,
}
}
// executeWithContext executes the query with query window and step context for partial value detection.
func (q *builderQuery[T]) executeWithContext(ctx context.Context, query string, args []any) (*qbtypes.Result, error) {
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
@@ -310,42 +408,27 @@ func (q *builderQuery[T]) executeWindowList(ctx context.Context) (*qbtypes.Resul
totalBytes := uint64(0)
start := time.Now()
// Check if filter contains trace_id(s) and optimize time range if needed
if q.spec.Signal == telemetrytypes.SignalTraces &&
q.spec.Filter != nil && q.spec.Filter.Expression != "" {
traceIDs, found := telemetrytraces.ExtractTraceIDsFromFilter(q.spec.Filter.Expression)
if found && len(traceIDs) > 0 {
finder := telemetrytraces.NewTraceTimeRangeFinder(q.telemetryStore)
traceStart, traceEnd, ok := finder.GetTraceTimeRangeMulti(ctx, traceIDs)
traceStartMS := uint64(traceStart) / 1_000_000
traceEndMS := uint64(traceEnd) / 1_000_000
if !ok {
q.logger.DebugContext(ctx, "failed to get trace time range", slog.Any("trace_ids", traceIDs))
} else if traceStartMS > 0 && traceEndMS > 0 {
// no overlap — nothing to return
if uint64(traceStartMS) > toMS || uint64(traceEndMS) < fromMS {
return &qbtypes.Result{
Type: qbtypes.RequestTypeRaw,
Value: &qbtypes.RawData{
QueryName: q.spec.Name,
},
Stats: qbtypes.ExecStats{
DurationMS: uint64(time.Since(start).Milliseconds()),
},
}, nil
}
// clamp window to trace time range before bucketing
if uint64(traceStartMS) > fromMS {
fromMS = uint64(traceStartMS)
}
if uint64(traceEndMS) < toMS {
toMS = uint64(traceEndMS)
}
q.logger.DebugContext(ctx, "optimized time range for traces", slog.Any("trace_ids", traceIDs), slog.Uint64("start", fromMS), slog.Uint64("end", toMS))
// Check if filter contains trace_id(s) and optimize time range if needed.
// Applies to both traces (the listing this branch was built for) and logs
// (which carry trace_id and benefit from the same clamp before bucketing).
if q.spec.Signal == telemetrytypes.SignalTraces || q.spec.Signal == telemetrytypes.SignalLogs {
var overlap bool
var warning string
fromMS, toMS, overlap, warning = q.narrowWindowByTraceID(ctx, fromMS, toMS)
if !overlap {
res := &qbtypes.Result{
Type: qbtypes.RequestTypeRaw,
Value: &qbtypes.RawData{
QueryName: q.spec.Name,
},
Stats: qbtypes.ExecStats{
DurationMS: uint64(time.Since(start).Milliseconds()),
},
}
if warning != "" {
res.Warnings = []string{warning}
}
return res, nil
}
}

View File

@@ -1,5 +0,0 @@
# SigNoz Cloud Integrations
Cloud integrations are unlike the rest of SigNoz integrations.
They have a different UX and so require a different API.
They will also be limited in number and are not expected to have community contributed implementations

View File

@@ -1,220 +0,0 @@
package cloudintegrations
import (
"context"
"database/sql"
"fmt"
"strings"
"time"
"github.com/SigNoz/signoz/pkg/query-service/model"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/integrationtypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type cloudProviderAccountsRepository interface {
listConnected(ctx context.Context, orgId string, provider string) ([]integrationtypes.CloudIntegration, *model.ApiError)
get(ctx context.Context, orgId string, provider string, id string) (*integrationtypes.CloudIntegration, *model.ApiError)
getConnectedCloudAccount(ctx context.Context, orgId string, provider string, accountID string) (*integrationtypes.CloudIntegration, *model.ApiError)
// Insert an account or update it by (cloudProvider, id)
// for specified non-empty fields
upsert(
ctx context.Context,
orgId string,
provider string,
id *string,
config *integrationtypes.AccountConfig,
accountId *string,
agentReport *integrationtypes.AgentReport,
removedAt *time.Time,
) (*integrationtypes.CloudIntegration, *model.ApiError)
}
func newCloudProviderAccountsRepository(store sqlstore.SQLStore) (
*cloudProviderAccountsSQLRepository, error,
) {
return &cloudProviderAccountsSQLRepository{
store: store,
}, nil
}
type cloudProviderAccountsSQLRepository struct {
store sqlstore.SQLStore
}
func (r *cloudProviderAccountsSQLRepository) listConnected(
ctx context.Context, orgId string, cloudProvider string,
) ([]integrationtypes.CloudIntegration, *model.ApiError) {
accounts := []integrationtypes.CloudIntegration{}
err := r.store.BunDB().NewSelect().
Model(&accounts).
Where("org_id = ?", orgId).
Where("provider = ?", cloudProvider).
Where("removed_at is NULL").
Where("account_id is not NULL").
Where("last_agent_report is not NULL").
Order("created_at").
Scan(ctx)
if err != nil {
return nil, model.InternalError(fmt.Errorf(
"could not query connected cloud accounts: %w", err,
))
}
return accounts, nil
}
func (r *cloudProviderAccountsSQLRepository) get(
ctx context.Context, orgId string, provider string, id string,
) (*integrationtypes.CloudIntegration, *model.ApiError) {
var result integrationtypes.CloudIntegration
err := r.store.BunDB().NewSelect().
Model(&result).
Where("org_id = ?", orgId).
Where("provider = ?", provider).
Where("id = ?", id).
Scan(ctx)
if err == sql.ErrNoRows {
return nil, model.NotFoundError(fmt.Errorf(
"couldn't find account with Id %s", id,
))
} else if err != nil {
return nil, model.InternalError(fmt.Errorf(
"couldn't query cloud provider accounts: %w", err,
))
}
return &result, nil
}
func (r *cloudProviderAccountsSQLRepository) getConnectedCloudAccount(
ctx context.Context, orgId string, provider string, accountId string,
) (*integrationtypes.CloudIntegration, *model.ApiError) {
var result integrationtypes.CloudIntegration
err := r.store.BunDB().NewSelect().
Model(&result).
Where("org_id = ?", orgId).
Where("provider = ?", provider).
Where("account_id = ?", accountId).
Where("last_agent_report is not NULL").
Where("removed_at is NULL").
Scan(ctx)
if err == sql.ErrNoRows {
return nil, model.NotFoundError(fmt.Errorf(
"couldn't find connected cloud account %s", accountId,
))
} else if err != nil {
return nil, model.InternalError(fmt.Errorf(
"couldn't query cloud provider accounts: %w", err,
))
}
return &result, nil
}
func (r *cloudProviderAccountsSQLRepository) upsert(
ctx context.Context,
orgId string,
provider string,
id *string,
config *integrationtypes.AccountConfig,
accountId *string,
agentReport *integrationtypes.AgentReport,
removedAt *time.Time,
) (*integrationtypes.CloudIntegration, *model.ApiError) {
// Insert
if id == nil {
temp := valuer.GenerateUUID().StringValue()
id = &temp
}
// Prepare clause for setting values in `on conflict do update`
onConflictSetStmts := []string{}
setColStatement := func(col string) string {
return fmt.Sprintf("%s=excluded.%s", col, col)
}
if config != nil {
onConflictSetStmts = append(
onConflictSetStmts, setColStatement("config"),
)
}
if accountId != nil {
onConflictSetStmts = append(
onConflictSetStmts, setColStatement("account_id"),
)
}
if agentReport != nil {
onConflictSetStmts = append(
onConflictSetStmts, setColStatement("last_agent_report"),
)
}
if removedAt != nil {
onConflictSetStmts = append(
onConflictSetStmts, setColStatement("removed_at"),
)
}
// set updated_at to current timestamp if it's an upsert
onConflictSetStmts = append(
onConflictSetStmts, setColStatement("updated_at"),
)
onConflictClause := ""
if len(onConflictSetStmts) > 0 {
onConflictClause = fmt.Sprintf(
"conflict(id) do update SET\n%s",
strings.Join(onConflictSetStmts, ",\n"),
)
}
integration := integrationtypes.CloudIntegration{
OrgID: orgId,
Provider: provider,
Identifiable: types.Identifiable{ID: valuer.MustNewUUID(*id)},
TimeAuditable: types.TimeAuditable{
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
Config: config,
AccountID: accountId,
LastAgentReport: agentReport,
RemovedAt: removedAt,
}
_, dbErr := r.store.BunDB().NewInsert().
Model(&integration).
On(onConflictClause).
Exec(ctx)
if dbErr != nil {
// for now returning internal error even if there is a conflict,
// will be handled better in the future iteration
return nil, model.InternalError(fmt.Errorf(
"could not upsert cloud account record: %w", dbErr,
))
}
upsertedAccount, apiErr := r.get(ctx, orgId, provider, *id)
if apiErr != nil {
return nil, model.InternalError(fmt.Errorf(
"couldn't fetch upserted account by id: %w", apiErr.ToError(),
))
}
return upsertedAccount, nil
}

View File

@@ -1,43 +0,0 @@
package cloudintegrations
import (
"github.com/SigNoz/signoz/pkg/errors"
)
var (
CodeInvalidCloudRegion = errors.MustNewCode("invalid_cloud_region")
CodeMismatchCloudProvider = errors.MustNewCode("cloud_provider_mismatch")
)
// List of all valid cloud regions on Amazon Web Services
var ValidAWSRegions = map[string]bool{
"af-south-1": true, // Africa (Cape Town).
"ap-east-1": true, // Asia Pacific (Hong Kong).
"ap-northeast-1": true, // Asia Pacific (Tokyo).
"ap-northeast-2": true, // Asia Pacific (Seoul).
"ap-northeast-3": true, // Asia Pacific (Osaka).
"ap-south-1": true, // Asia Pacific (Mumbai).
"ap-south-2": true, // Asia Pacific (Hyderabad).
"ap-southeast-1": true, // Asia Pacific (Singapore).
"ap-southeast-2": true, // Asia Pacific (Sydney).
"ap-southeast-3": true, // Asia Pacific (Jakarta).
"ap-southeast-4": true, // Asia Pacific (Melbourne).
"ca-central-1": true, // Canada (Central).
"ca-west-1": true, // Canada West (Calgary).
"eu-central-1": true, // Europe (Frankfurt).
"eu-central-2": true, // Europe (Zurich).
"eu-north-1": true, // Europe (Stockholm).
"eu-south-1": true, // Europe (Milan).
"eu-south-2": true, // Europe (Spain).
"eu-west-1": true, // Europe (Ireland).
"eu-west-2": true, // Europe (London).
"eu-west-3": true, // Europe (Paris).
"il-central-1": true, // Israel (Tel Aviv).
"me-central-1": true, // Middle East (UAE).
"me-south-1": true, // Middle East (Bahrain).
"sa-east-1": true, // South America (Sao Paulo).
"us-east-1": true, // US East (N. Virginia).
"us-east-2": true, // US East (Ohio).
"us-west-1": true, // US West (N. California).
"us-west-2": true, // US West (Oregon).
}

View File

@@ -1,625 +0,0 @@
package cloudintegrations
import (
"context"
"fmt"
"net/url"
"slices"
"strings"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/query-service/app/cloudintegrations/services"
"github.com/SigNoz/signoz/pkg/query-service/model"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/types/integrationtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"golang.org/x/exp/maps"
)
var SupportedCloudProviders = []string{
"aws",
}
func validateCloudProviderName(name string) *model.ApiError {
if !slices.Contains(SupportedCloudProviders, name) {
return model.BadRequest(fmt.Errorf("invalid cloud provider: %s", name))
}
return nil
}
type Controller struct {
accountsRepo cloudProviderAccountsRepository
serviceConfigRepo ServiceConfigDatabase
}
func NewController(sqlStore sqlstore.SQLStore) (*Controller, error) {
accountsRepo, err := newCloudProviderAccountsRepository(sqlStore)
if err != nil {
return nil, fmt.Errorf("couldn't create cloud provider accounts repo: %w", err)
}
serviceConfigRepo, err := newServiceConfigRepository(sqlStore)
if err != nil {
return nil, fmt.Errorf("couldn't create cloud provider service config repo: %w", err)
}
return &Controller{
accountsRepo: accountsRepo,
serviceConfigRepo: serviceConfigRepo,
}, nil
}
type ConnectedAccountsListResponse struct {
Accounts []integrationtypes.Account `json:"accounts"`
}
func (c *Controller) ListConnectedAccounts(ctx context.Context, orgId string, cloudProvider string) (
*ConnectedAccountsListResponse, *model.ApiError,
) {
if apiErr := validateCloudProviderName(cloudProvider); apiErr != nil {
return nil, apiErr
}
accountRecords, apiErr := c.accountsRepo.listConnected(ctx, orgId, cloudProvider)
if apiErr != nil {
return nil, model.WrapApiError(apiErr, "couldn't list cloud accounts")
}
connectedAccounts := []integrationtypes.Account{}
for _, a := range accountRecords {
connectedAccounts = append(connectedAccounts, a.Account())
}
return &ConnectedAccountsListResponse{
Accounts: connectedAccounts,
}, nil
}
type GenerateConnectionUrlRequest struct {
// Optional. To be specified for updates.
AccountId *string `json:"account_id,omitempty"`
AccountConfig integrationtypes.AccountConfig `json:"account_config"`
AgentConfig SigNozAgentConfig `json:"agent_config"`
}
type SigNozAgentConfig struct {
// The region in which SigNoz agent should be installed.
Region string `json:"region"`
IngestionUrl string `json:"ingestion_url"`
IngestionKey string `json:"ingestion_key"`
SigNozAPIUrl string `json:"signoz_api_url"`
SigNozAPIKey string `json:"signoz_api_key"`
Version string `json:"version,omitempty"`
}
type GenerateConnectionUrlResponse struct {
AccountId string `json:"account_id"`
ConnectionUrl string `json:"connection_url"`
}
func (c *Controller) GenerateConnectionUrl(ctx context.Context, orgId string, cloudProvider string, req GenerateConnectionUrlRequest) (*GenerateConnectionUrlResponse, *model.ApiError) {
// Account connection with a simple connection URL may not be available for all providers.
if cloudProvider != "aws" {
return nil, model.BadRequest(fmt.Errorf("unsupported cloud provider: %s", cloudProvider))
}
account, apiErr := c.accountsRepo.upsert(
ctx, orgId, cloudProvider, req.AccountId, &req.AccountConfig, nil, nil, nil,
)
if apiErr != nil {
return nil, model.WrapApiError(apiErr, "couldn't upsert cloud account")
}
agentVersion := "v0.0.8"
if req.AgentConfig.Version != "" {
agentVersion = req.AgentConfig.Version
}
connectionUrl := fmt.Sprintf(
"https://%s.console.aws.amazon.com/cloudformation/home?region=%s#/stacks/quickcreate?",
req.AgentConfig.Region, req.AgentConfig.Region,
)
for qp, value := range map[string]string{
"param_SigNozIntegrationAgentVersion": agentVersion,
"param_SigNozApiUrl": req.AgentConfig.SigNozAPIUrl,
"param_SigNozApiKey": req.AgentConfig.SigNozAPIKey,
"param_SigNozAccountId": account.ID.StringValue(),
"param_IngestionUrl": req.AgentConfig.IngestionUrl,
"param_IngestionKey": req.AgentConfig.IngestionKey,
"stackName": "signoz-integration",
"templateURL": fmt.Sprintf(
"https://signoz-integrations.s3.us-east-1.amazonaws.com/aws-quickcreate-template-%s.json",
agentVersion,
),
} {
connectionUrl += fmt.Sprintf("&%s=%s", qp, url.QueryEscape(value))
}
return &GenerateConnectionUrlResponse{
AccountId: account.ID.StringValue(),
ConnectionUrl: connectionUrl,
}, nil
}
type AccountStatusResponse struct {
Id string `json:"id"`
CloudAccountId *string `json:"cloud_account_id,omitempty"`
Status integrationtypes.AccountStatus `json:"status"`
}
func (c *Controller) GetAccountStatus(ctx context.Context, orgId string, cloudProvider string, accountId string) (
*AccountStatusResponse, *model.ApiError,
) {
if apiErr := validateCloudProviderName(cloudProvider); apiErr != nil {
return nil, apiErr
}
account, apiErr := c.accountsRepo.get(ctx, orgId, cloudProvider, accountId)
if apiErr != nil {
return nil, apiErr
}
resp := AccountStatusResponse{
Id: account.ID.StringValue(),
CloudAccountId: account.AccountID,
Status: account.Status(),
}
return &resp, nil
}
type AgentCheckInRequest struct {
ID string `json:"account_id"`
AccountID string `json:"cloud_account_id"`
// Arbitrary cloud specific Agent data
Data map[string]any `json:"data,omitempty"`
}
type AgentCheckInResponse struct {
AccountId string `json:"account_id"`
CloudAccountId string `json:"cloud_account_id"`
RemovedAt *time.Time `json:"removed_at"`
IntegrationConfig IntegrationConfigForAgent `json:"integration_config"`
}
type IntegrationConfigForAgent struct {
EnabledRegions []string `json:"enabled_regions"`
TelemetryCollectionStrategy *CompiledCollectionStrategy `json:"telemetry,omitempty"`
}
func (c *Controller) CheckInAsAgent(ctx context.Context, orgId string, cloudProvider string, req AgentCheckInRequest) (*AgentCheckInResponse, error) {
if apiErr := validateCloudProviderName(cloudProvider); apiErr != nil {
return nil, apiErr
}
existingAccount, apiErr := c.accountsRepo.get(ctx, orgId, cloudProvider, req.ID)
if existingAccount != nil && existingAccount.AccountID != nil && *existingAccount.AccountID != req.AccountID {
return nil, model.BadRequest(fmt.Errorf(
"can't check in with new %s account id %s for account %s with existing %s id %s",
cloudProvider, req.AccountID, existingAccount.ID.StringValue(), cloudProvider, *existingAccount.AccountID,
))
}
existingAccount, apiErr = c.accountsRepo.getConnectedCloudAccount(ctx, orgId, cloudProvider, req.AccountID)
if existingAccount != nil && existingAccount.ID.StringValue() != req.ID {
return nil, model.BadRequest(fmt.Errorf(
"can't check in to %s account %s with id %s. already connected with id %s",
cloudProvider, req.AccountID, req.ID, existingAccount.ID.StringValue(),
))
}
agentReport := integrationtypes.AgentReport{
TimestampMillis: time.Now().UnixMilli(),
Data: req.Data,
}
account, apiErr := c.accountsRepo.upsert(
ctx, orgId, cloudProvider, &req.ID, nil, &req.AccountID, &agentReport, nil,
)
if apiErr != nil {
return nil, model.WrapApiError(apiErr, "couldn't upsert cloud account")
}
// prepare and return integration config to be consumed by agent
compiledStrategy, err := NewCompiledCollectionStrategy(cloudProvider)
if err != nil {
return nil, model.InternalError(fmt.Errorf(
"couldn't init telemetry collection strategy: %w", err,
))
}
agentConfig := IntegrationConfigForAgent{
EnabledRegions: []string{},
TelemetryCollectionStrategy: compiledStrategy,
}
if account.Config != nil && account.Config.EnabledRegions != nil {
agentConfig.EnabledRegions = account.Config.EnabledRegions
}
services, err := services.Map(cloudProvider)
if err != nil {
return nil, err
}
svcConfigs, apiErr := c.serviceConfigRepo.getAllForAccount(
ctx, orgId, account.ID.StringValue(),
)
if apiErr != nil {
return nil, model.WrapApiError(
apiErr, "couldn't get service configs for cloud account",
)
}
// accumulate config in a fixed order to ensure same config generated across runs
configuredServices := maps.Keys(svcConfigs)
slices.Sort(configuredServices)
for _, svcType := range configuredServices {
definition, ok := services[svcType]
if !ok {
continue
}
config := svcConfigs[svcType]
err := AddServiceStrategy(svcType, compiledStrategy, definition.Strategy, config)
if err != nil {
return nil, err
}
}
return &AgentCheckInResponse{
AccountId: account.ID.StringValue(),
CloudAccountId: *account.AccountID,
RemovedAt: account.RemovedAt,
IntegrationConfig: agentConfig,
}, nil
}
type UpdateAccountConfigRequest struct {
Config integrationtypes.AccountConfig `json:"config"`
}
func (c *Controller) UpdateAccountConfig(ctx context.Context, orgId string, cloudProvider string, accountId string, req UpdateAccountConfigRequest) (*integrationtypes.Account, *model.ApiError) {
if apiErr := validateCloudProviderName(cloudProvider); apiErr != nil {
return nil, apiErr
}
accountRecord, apiErr := c.accountsRepo.upsert(
ctx, orgId, cloudProvider, &accountId, &req.Config, nil, nil, nil,
)
if apiErr != nil {
return nil, model.WrapApiError(apiErr, "couldn't upsert cloud account")
}
account := accountRecord.Account()
return &account, nil
}
func (c *Controller) DisconnectAccount(ctx context.Context, orgId string, cloudProvider string, accountId string) (*integrationtypes.CloudIntegration, *model.ApiError) {
if apiErr := validateCloudProviderName(cloudProvider); apiErr != nil {
return nil, apiErr
}
account, apiErr := c.accountsRepo.get(ctx, orgId, cloudProvider, accountId)
if apiErr != nil {
return nil, model.WrapApiError(apiErr, "couldn't disconnect account")
}
tsNow := time.Now()
account, apiErr = c.accountsRepo.upsert(
ctx, orgId, cloudProvider, &accountId, nil, nil, nil, &tsNow,
)
if apiErr != nil {
return nil, model.WrapApiError(apiErr, "couldn't disconnect account")
}
return account, nil
}
type ListServicesResponse struct {
Services []ServiceSummary `json:"services"`
}
func (c *Controller) ListServices(
ctx context.Context,
orgID string,
cloudProvider string,
cloudAccountId *string,
) (*ListServicesResponse, *model.ApiError) {
if apiErr := validateCloudProviderName(cloudProvider); apiErr != nil {
return nil, apiErr
}
definitions, apiErr := services.List(cloudProvider)
if apiErr != nil {
return nil, model.WrapApiError(apiErr, "couldn't list cloud services")
}
svcConfigs := map[string]*integrationtypes.CloudServiceConfig{}
if cloudAccountId != nil {
activeAccount, apiErr := c.accountsRepo.getConnectedCloudAccount(
ctx, orgID, cloudProvider, *cloudAccountId,
)
if apiErr != nil {
return nil, model.WrapApiError(apiErr, "couldn't get active account")
}
svcConfigs, apiErr = c.serviceConfigRepo.getAllForAccount(
ctx, orgID, activeAccount.ID.StringValue(),
)
if apiErr != nil {
return nil, model.WrapApiError(
apiErr, "couldn't get service configs for cloud account",
)
}
}
summaries := []ServiceSummary{}
for _, def := range definitions {
summary := ServiceSummary{
Metadata: def.Metadata,
}
summary.Config = svcConfigs[summary.Id]
summaries = append(summaries, summary)
}
return &ListServicesResponse{
Services: summaries,
}, nil
}
func (c *Controller) GetServiceDetails(
ctx context.Context,
orgID string,
cloudProvider string,
serviceId string,
cloudAccountId *string,
) (*ServiceDetails, error) {
if apiErr := validateCloudProviderName(cloudProvider); apiErr != nil {
return nil, apiErr
}
definition, err := services.GetServiceDefinition(cloudProvider, serviceId)
if err != nil {
return nil, err
}
details := ServiceDetails{
Definition: *definition,
}
if cloudAccountId != nil {
activeAccount, apiErr := c.accountsRepo.getConnectedCloudAccount(
ctx, orgID, cloudProvider, *cloudAccountId,
)
if apiErr != nil {
return nil, model.WrapApiError(apiErr, "couldn't get active account")
}
config, apiErr := c.serviceConfigRepo.get(
ctx, orgID, activeAccount.ID.StringValue(), serviceId,
)
if apiErr != nil && apiErr.Type() != model.ErrorNotFound {
return nil, model.WrapApiError(apiErr, "couldn't fetch service config")
}
if config != nil {
details.Config = config
enabled := false
if config.Metrics != nil && config.Metrics.Enabled {
enabled = true
}
// add links to service dashboards, making them clickable.
for i, d := range definition.Assets.Dashboards {
dashboardUuid := c.dashboardUuid(
cloudProvider, serviceId, d.Id,
)
if enabled {
definition.Assets.Dashboards[i].Url = fmt.Sprintf("/dashboard/%s", dashboardUuid)
} else {
definition.Assets.Dashboards[i].Url = "" // to unset the in-memory URL if enabled once and disabled afterwards
}
}
}
}
return &details, nil
}
type UpdateServiceConfigRequest struct {
CloudAccountId string `json:"cloud_account_id"`
Config integrationtypes.CloudServiceConfig `json:"config"`
}
func (u *UpdateServiceConfigRequest) Validate(def *services.Definition) error {
if def.Id != services.S3Sync && u.Config.Logs != nil && u.Config.Logs.S3Buckets != nil {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "s3 buckets can only be added to service-type[%s]", services.S3Sync)
} else if def.Id == services.S3Sync && u.Config.Logs != nil && u.Config.Logs.S3Buckets != nil {
for region := range u.Config.Logs.S3Buckets {
if _, found := ValidAWSRegions[region]; !found {
return errors.NewInvalidInputf(CodeInvalidCloudRegion, "invalid cloud region: %s", region)
}
}
}
return nil
}
type UpdateServiceConfigResponse struct {
Id string `json:"id"`
Config integrationtypes.CloudServiceConfig `json:"config"`
}
func (c *Controller) UpdateServiceConfig(
ctx context.Context,
orgID string,
cloudProvider string,
serviceType string,
req *UpdateServiceConfigRequest,
) (*UpdateServiceConfigResponse, error) {
if apiErr := validateCloudProviderName(cloudProvider); apiErr != nil {
return nil, apiErr
}
// can only update config for a valid service.
definition, err := services.GetServiceDefinition(cloudProvider, serviceType)
if err != nil {
return nil, err
}
if err := req.Validate(definition); err != nil {
return nil, err
}
// can only update config for a connected cloud account id
_, apiErr := c.accountsRepo.getConnectedCloudAccount(
ctx, orgID, cloudProvider, req.CloudAccountId,
)
if apiErr != nil {
return nil, model.WrapApiError(apiErr, "couldn't find connected cloud account")
}
updatedConfig, apiErr := c.serviceConfigRepo.upsert(
ctx, orgID, cloudProvider, req.CloudAccountId, serviceType, req.Config,
)
if apiErr != nil {
return nil, model.WrapApiError(apiErr, "couldn't update service config")
}
return &UpdateServiceConfigResponse{
Id: serviceType,
Config: *updatedConfig,
}, nil
}
// All dashboards that are available based on cloud integrations configuration
// across all cloud providers
func (c *Controller) AvailableDashboards(ctx context.Context, orgId valuer.UUID) ([]*dashboardtypes.Dashboard, *model.ApiError) {
allDashboards := []*dashboardtypes.Dashboard{}
for _, provider := range []string{"aws"} {
providerDashboards, apiErr := c.AvailableDashboardsForCloudProvider(ctx, orgId, provider)
if apiErr != nil {
return nil, model.WrapApiError(
apiErr, fmt.Sprintf("couldn't get available dashboards for %s", provider),
)
}
allDashboards = append(allDashboards, providerDashboards...)
}
return allDashboards, nil
}
func (c *Controller) AvailableDashboardsForCloudProvider(ctx context.Context, orgID valuer.UUID, cloudProvider string) ([]*dashboardtypes.Dashboard, *model.ApiError) {
accountRecords, apiErr := c.accountsRepo.listConnected(ctx, orgID.StringValue(), cloudProvider)
if apiErr != nil {
return nil, model.WrapApiError(apiErr, "couldn't list connected cloud accounts")
}
// for v0, service dashboards are only available when metrics are enabled.
servicesWithAvailableMetrics := map[string]*time.Time{}
for _, ar := range accountRecords {
if ar.AccountID != nil {
configsBySvcId, apiErr := c.serviceConfigRepo.getAllForAccount(
ctx, orgID.StringValue(), ar.ID.StringValue(),
)
if apiErr != nil {
return nil, apiErr
}
for svcId, config := range configsBySvcId {
if config.Metrics != nil && config.Metrics.Enabled {
servicesWithAvailableMetrics[svcId] = &ar.CreatedAt
}
}
}
}
allServices, apiErr := services.List(cloudProvider)
if apiErr != nil {
return nil, apiErr
}
svcDashboards := []*dashboardtypes.Dashboard{}
for _, svc := range allServices {
serviceDashboardsCreatedAt := servicesWithAvailableMetrics[svc.Id]
if serviceDashboardsCreatedAt != nil {
for _, d := range svc.Assets.Dashboards {
author := fmt.Sprintf("%s-integration", cloudProvider)
svcDashboards = append(svcDashboards, &dashboardtypes.Dashboard{
ID: c.dashboardUuid(cloudProvider, svc.Id, d.Id),
Locked: true,
Data: *d.Definition,
TimeAuditable: types.TimeAuditable{
CreatedAt: *serviceDashboardsCreatedAt,
UpdatedAt: *serviceDashboardsCreatedAt,
},
UserAuditable: types.UserAuditable{
CreatedBy: author,
UpdatedBy: author,
},
OrgID: orgID,
})
}
servicesWithAvailableMetrics[svc.Id] = nil
}
}
return svcDashboards, nil
}
func (c *Controller) GetDashboardById(ctx context.Context, orgId valuer.UUID, dashboardUuid string) (*dashboardtypes.Dashboard, *model.ApiError) {
cloudProvider, _, _, apiErr := c.parseDashboardUuid(dashboardUuid)
if apiErr != nil {
return nil, apiErr
}
allDashboards, apiErr := c.AvailableDashboardsForCloudProvider(ctx, orgId, cloudProvider)
if apiErr != nil {
return nil, model.WrapApiError(apiErr, "couldn't list available dashboards")
}
for _, d := range allDashboards {
if d.ID == dashboardUuid {
return d, nil
}
}
return nil, model.NotFoundError(fmt.Errorf("couldn't find dashboard with uuid: %s", dashboardUuid))
}
func (c *Controller) dashboardUuid(
cloudProvider string, svcId string, dashboardId string,
) string {
return fmt.Sprintf("cloud-integration--%s--%s--%s", cloudProvider, svcId, dashboardId)
}
func (c *Controller) parseDashboardUuid(dashboardUuid string) (cloudProvider string, svcId string, dashboardId string, apiErr *model.ApiError) {
parts := strings.SplitN(dashboardUuid, "--", 4)
if len(parts) != 4 || parts[0] != "cloud-integration" {
return "", "", "", model.BadRequest(fmt.Errorf("invalid cloud integration dashboard id"))
}
return parts[1], parts[2], parts[3], nil
}
func (c *Controller) IsCloudIntegrationDashboardUuid(dashboardUuid string) bool {
_, _, _, apiErr := c.parseDashboardUuid(dashboardUuid)
return apiErr == nil
}

View File

@@ -1,94 +0,0 @@
package cloudintegrations
import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/query-service/app/cloudintegrations/services"
"github.com/SigNoz/signoz/pkg/types/integrationtypes"
)
type ServiceSummary struct {
services.Metadata
Config *integrationtypes.CloudServiceConfig `json:"config"`
}
type ServiceDetails struct {
services.Definition
Config *integrationtypes.CloudServiceConfig `json:"config"`
ConnectionStatus *ServiceConnectionStatus `json:"status,omitempty"`
}
type AccountStatus struct {
Integration AccountIntegrationStatus `json:"integration"`
}
type AccountIntegrationStatus struct {
LastHeartbeatTsMillis *int64 `json:"last_heartbeat_ts_ms"`
}
type LogsConfig struct {
Enabled bool `json:"enabled"`
S3Buckets map[string][]string `json:"s3_buckets,omitempty"`
}
type MetricsConfig struct {
Enabled bool `json:"enabled"`
}
type ServiceConnectionStatus struct {
Logs *SignalConnectionStatus `json:"logs"`
Metrics *SignalConnectionStatus `json:"metrics"`
}
type SignalConnectionStatus struct {
LastReceivedTsMillis int64 `json:"last_received_ts_ms"` // epoch milliseconds
LastReceivedFrom string `json:"last_received_from"` // resource identifier
}
type CompiledCollectionStrategy = services.CollectionStrategy
func NewCompiledCollectionStrategy(provider string) (*CompiledCollectionStrategy, error) {
if provider == "aws" {
return &CompiledCollectionStrategy{
Provider: "aws",
AWSMetrics: &services.AWSMetricsStrategy{},
AWSLogs: &services.AWSLogsStrategy{},
}, nil
}
return nil, errors.NewNotFoundf(services.CodeUnsupportedCloudProvider, "unsupported cloud provider: %s", provider)
}
// Helper for accumulating strategies for enabled services.
func AddServiceStrategy(serviceType string, cs *CompiledCollectionStrategy,
definitionStrat *services.CollectionStrategy, config *integrationtypes.CloudServiceConfig) error {
if definitionStrat.Provider != cs.Provider {
return errors.NewInternalf(CodeMismatchCloudProvider, "can't add %s service strategy to compiled strategy for %s",
definitionStrat.Provider, cs.Provider)
}
if cs.Provider == "aws" {
if config.Logs != nil && config.Logs.Enabled {
if serviceType == services.S3Sync {
// S3 bucket sync; No cloudwatch logs are appended for this service type;
// Though definition is populated with a custom cloudwatch group that helps in calculating logs connection status
cs.S3Buckets = config.Logs.S3Buckets
} else if definitionStrat.AWSLogs != nil { // services that includes a logs subscription
cs.AWSLogs.Subscriptions = append(
cs.AWSLogs.Subscriptions,
definitionStrat.AWSLogs.Subscriptions...,
)
}
}
if config.Metrics != nil && config.Metrics.Enabled && definitionStrat.AWSMetrics != nil {
cs.AWSMetrics.StreamFilters = append(
cs.AWSMetrics.StreamFilters,
definitionStrat.AWSMetrics.StreamFilters...,
)
}
return nil
}
return errors.NewNotFoundf(services.CodeUnsupportedCloudProvider, "unsupported cloud provider: %s", cs.Provider)
}

View File

@@ -1,165 +0,0 @@
package cloudintegrations
import (
"context"
"database/sql"
"fmt"
"time"
"github.com/SigNoz/signoz/pkg/query-service/model"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/integrationtypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type ServiceConfigDatabase interface {
get(
ctx context.Context,
orgID string,
cloudAccountId string,
serviceType string,
) (*integrationtypes.CloudServiceConfig, *model.ApiError)
upsert(
ctx context.Context,
orgID string,
cloudProvider string,
cloudAccountId string,
serviceId string,
config integrationtypes.CloudServiceConfig,
) (*integrationtypes.CloudServiceConfig, *model.ApiError)
getAllForAccount(
ctx context.Context,
orgID string,
cloudAccountId string,
) (
configsBySvcId map[string]*integrationtypes.CloudServiceConfig,
apiErr *model.ApiError,
)
}
func newServiceConfigRepository(store sqlstore.SQLStore) (
*serviceConfigSQLRepository, error,
) {
return &serviceConfigSQLRepository{
store: store,
}, nil
}
type serviceConfigSQLRepository struct {
store sqlstore.SQLStore
}
func (r *serviceConfigSQLRepository) get(
ctx context.Context,
orgID string,
cloudAccountId string,
serviceType string,
) (*integrationtypes.CloudServiceConfig, *model.ApiError) {
var result integrationtypes.CloudIntegrationService
err := r.store.BunDB().NewSelect().
Model(&result).
Join("JOIN cloud_integration ci ON ci.id = cis.cloud_integration_id").
Where("ci.org_id = ?", orgID).
Where("ci.id = ?", cloudAccountId).
Where("cis.type = ?", serviceType).
Scan(ctx)
if err == sql.ErrNoRows {
return nil, model.NotFoundError(fmt.Errorf(
"couldn't find config for cloud account %s",
cloudAccountId,
))
} else if err != nil {
return nil, model.InternalError(fmt.Errorf(
"couldn't query cloud service config: %w", err,
))
}
return &result.Config, nil
}
func (r *serviceConfigSQLRepository) upsert(
ctx context.Context,
orgID string,
cloudProvider string,
cloudAccountId string,
serviceId string,
config integrationtypes.CloudServiceConfig,
) (*integrationtypes.CloudServiceConfig, *model.ApiError) {
// get cloud integration id from account id
// if the account is not connected, we don't need to upsert the config
var cloudIntegrationId string
err := r.store.BunDB().NewSelect().
Model((*integrationtypes.CloudIntegration)(nil)).
Column("id").
Where("provider = ?", cloudProvider).
Where("account_id = ?", cloudAccountId).
Where("org_id = ?", orgID).
Where("removed_at is NULL").
Where("last_agent_report is not NULL").
Scan(ctx, &cloudIntegrationId)
if err != nil {
return nil, model.InternalError(fmt.Errorf(
"couldn't query cloud integration id: %w", err,
))
}
serviceConfig := integrationtypes.CloudIntegrationService{
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
TimeAuditable: types.TimeAuditable{
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
Config: config,
Type: serviceId,
CloudIntegrationID: cloudIntegrationId,
}
_, err = r.store.BunDB().NewInsert().
Model(&serviceConfig).
On("conflict(cloud_integration_id, type) do update set config=excluded.config, updated_at=excluded.updated_at").
Exec(ctx)
if err != nil {
return nil, model.InternalError(fmt.Errorf(
"could not upsert cloud service config: %w", err,
))
}
return &serviceConfig.Config, nil
}
func (r *serviceConfigSQLRepository) getAllForAccount(
ctx context.Context,
orgID string,
cloudAccountId string,
) (map[string]*integrationtypes.CloudServiceConfig, *model.ApiError) {
serviceConfigs := []integrationtypes.CloudIntegrationService{}
err := r.store.BunDB().NewSelect().
Model(&serviceConfigs).
Join("JOIN cloud_integration ci ON ci.id = cis.cloud_integration_id").
Where("ci.id = ?", cloudAccountId).
Where("ci.org_id = ?", orgID).
Scan(ctx)
if err != nil {
return nil, model.InternalError(fmt.Errorf(
"could not query service configs from db: %w", err,
))
}
result := map[string]*integrationtypes.CloudServiceConfig{}
for _, r := range serviceConfigs {
result[r.Type] = &r.Config
}
return result, nil
}

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 85 85" fill="#fff" fill-rule="evenodd" stroke="#000" stroke-linecap="round" stroke-linejoin="round"><use xlink:href="#A" x="2.5" y="2.5"/><symbol id="A" overflow="visible"><g stroke="none"><path d="M0 41.579C0 20.293 17.84 3.157 40 3.157s40 17.136 40 38.422S62.16 80 40 80 0 62.864 0 41.579z" fill="#9d5025"/><path d="M0 38.422C0 17.136 17.84 0 40 0s40 17.136 40 38.422-17.84 38.422-40 38.422S0 59.707 0 38.422z" fill="#f58536"/><path d="M51.672 7.387v13.952H28.327V7.387zm18.061 40.378v11.364h-11.83V47.765zm-14.958 0v11.364h-11.83V47.765zm-18.206 0v11.364h-11.83V47.765zm-14.959 0v11.364H9.78V47.765z"/><path d="M14.63 37.929h2.13v11.149h-2.13z"/><path d="M14.63 37.929h17.088v2.045H14.63z"/><path d="M29.589 37.929h2.13v11.149H29.59zm18.206 0h2.13v11.149h-2.13z"/><path d="M47.795 37.929h17.088v2.045H47.795z"/><path d="M62.754 37.929h2.13v11.149h-2.129zm-40.631-7.954h2.13v8.977h-2.13zM38.935 19.28h2.13v10.859h-2.129z"/><path d="M22.123 29.116h35.32v2.045h-35.32z"/><path d="M55.314 29.975h2.13v8.977h-2.129z"/></g></symbol></svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1,467 +0,0 @@
{
"id": "alb",
"title": "ALB",
"icon": "file://icon.svg",
"overview": "file://overview.md",
"supported_signals": {
"metrics": true,
"logs": false
},
"data_collected": {
"metrics": [
{
"name": "aws_ApplicationELB_ActiveConnectionCount_count",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_ActiveConnectionCount_max",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_ActiveConnectionCount_min",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_ActiveConnectionCount_sum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_AnomalousHostCount_count",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_AnomalousHostCount_max",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_AnomalousHostCount_min",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_AnomalousHostCount_sum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_ConsumedLCUs_count",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_ConsumedLCUs_max",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_ConsumedLCUs_min",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_ConsumedLCUs_sum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_HTTPCode_Target_2XX_Count_count",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_HTTPCode_Target_2XX_Count_max",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_HTTPCode_Target_2XX_Count_min",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_HTTPCode_Target_2XX_Count_sum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_HTTPCode_Target_4XX_Count_count",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_HTTPCode_Target_4XX_Count_max",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_HTTPCode_Target_4XX_Count_min",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_HTTPCode_Target_4XX_Count_sum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_HealthyHostCount_count",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_HealthyHostCount_max",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_HealthyHostCount_min",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_HealthyHostCount_sum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_HealthyStateDNS_count",
"unit": "None",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_HealthyStateDNS_max",
"unit": "None",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_HealthyStateDNS_min",
"unit": "None",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_HealthyStateDNS_sum",
"unit": "None",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_HealthyStateRouting_count",
"unit": "None",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_HealthyStateRouting_max",
"unit": "None",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_HealthyStateRouting_min",
"unit": "None",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_HealthyStateRouting_sum",
"unit": "None",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_MitigatedHostCount_count",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_MitigatedHostCount_max",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_MitigatedHostCount_min",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_MitigatedHostCount_sum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_NewConnectionCount_count",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_NewConnectionCount_max",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_NewConnectionCount_min",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_NewConnectionCount_sum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_PeakLCUs_count",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_PeakLCUs_max",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_PeakLCUs_min",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_PeakLCUs_sum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_ProcessedBytes_count",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_ProcessedBytes_max",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_ProcessedBytes_min",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_ProcessedBytes_sum",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_RequestCountPerTarget_count",
"unit": "None",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_RequestCountPerTarget_max",
"unit": "None",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_RequestCountPerTarget_min",
"unit": "None",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_RequestCountPerTarget_sum",
"unit": "None",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_RequestCount_count",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_RequestCount_max",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_RequestCount_min",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_RequestCount_sum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_TargetResponseTime_count",
"unit": "Seconds",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_TargetResponseTime_max",
"unit": "Seconds",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_TargetResponseTime_min",
"unit": "Seconds",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_TargetResponseTime_sum",
"unit": "Seconds",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_UnHealthyHostCount_count",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_UnHealthyHostCount_max",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_UnHealthyHostCount_min",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_UnHealthyHostCount_sum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_UnhealthyStateDNS_count",
"unit": "None",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_UnhealthyStateDNS_max",
"unit": "None",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_UnhealthyStateDNS_min",
"unit": "None",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_UnhealthyStateDNS_sum",
"unit": "None",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_UnhealthyStateRouting_count",
"unit": "None",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_UnhealthyStateRouting_max",
"unit": "None",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_UnhealthyStateRouting_min",
"unit": "None",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_UnhealthyStateRouting_sum",
"unit": "None",
"type": "Gauge",
"description": ""
}
],
"logs": []
},
"telemetry_collection_strategy": {
"aws_metrics": {
"cloudwatch_metric_stream_filters": [
{
"Namespace": "AWS/ApplicationELB"
}
]
}
},
"assets": {
"dashboards": [
{
"id": "overview",
"title": "ALB Overview",
"description": "Overview of Application Load Balancer",
"image": "file://assets/dashboards/overview.png",
"definition": "file://assets/dashboards/overview.json"
}
]
}
}

View File

@@ -1,3 +0,0 @@
### Monitor Application Load Balancers with SigNoz
Collect key ALB metrics and view them with an out of the box dashboard.

View File

@@ -1,14 +0,0 @@
<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<defs>
<linearGradient x1="0%" y1="100%" x2="100%" y2="0%" id="linearGradient-1">
<stop stop-color="#4D27A8" offset="0%"></stop>
<stop stop-color="#A166FF" offset="100%"></stop>
</linearGradient>
</defs>
<g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g fill="url(#linearGradient-1)">
<rect id="Rectangle" x="0" y="0" width="24" height="24"></rect>
</g>
<path d="M6,6.76751613 L8,5.43446738 L8,18.5659476 L6,17.2328988 L6,6.76751613 Z M5,6.49950633 L5,17.4999086 C5,17.6669147 5.084,17.8239204 5.223,17.9159238 L8.223,19.9159969 C8.307,19.971999 8.403,20 8.5,20 C8.581,20 8.662,19.9809993 8.736,19.9409978 C8.898,19.8539947 9,19.6849885 9,19.4999817 L9,16.9998903 L10,16.9998903 L10,15.9998537 L9,15.9998537 L9,7.99956118 L10,7.99956118 L10,6.99952461 L9,6.99952461 L9,4.49943319 C9,4.31542646 8.898,4.14542025 8.736,4.0594171 C8.574,3.97241392 8.377,3.98141425 8.223,4.08341798 L5.223,6.08349112 C5.084,6.17649452 5,6.33250022 5,6.49950633 L5,6.49950633 Z M19,17.2328988 L17,18.5659476 L17,5.43446738 L19,6.76751613 L19,17.2328988 Z M19.777,6.08349112 L16.777,4.08341798 C16.623,3.98141425 16.426,3.97241392 16.264,4.0594171 C16.102,4.14542025 16,4.31542646 16,4.49943319 L16,6.99952461 L15,6.99952461 L15,7.99956118 L16,7.99956118 L16,15.9998537 L15,15.9998537 L15,16.9998903 L16,16.9998903 L16,19.4999817 C16,19.6849885 16.102,19.8539947 16.264,19.9409978 C16.338,19.9809993 16.419,20 16.5,20 C16.597,20 16.693,19.971999 16.777,19.9159969 L19.777,17.9159238 C19.916,17.8239204 20,17.6669147 20,17.4999086 L20,6.49950633 C20,6.33250022 19.916,6.17649452 19.777,6.08349112 L19.777,6.08349112 Z M13,7.99956118 L14,7.99956118 L14,6.99952461 L13,6.99952461 L13,7.99956118 Z M11,7.99956118 L12,7.99956118 L12,6.99952461 L11,6.99952461 L11,7.99956118 Z M13,16.9998903 L14,16.9998903 L14,15.9998537 L13,15.9998537 L13,16.9998903 Z M11,16.9998903 L12,16.9998903 L12,15.9998537 L11,15.9998537 L11,16.9998903 Z M13.18,14.884813 L10.18,12.3847215 C10.065,12.288718 10,12.1487129 10,11.9997075 C10,11.851702 10.065,11.7106969 10.18,11.6156934 L13.18,9.11560199 L13.82,9.88463011 L11.281,11.9997075 L13.82,14.1157848 L13.18,14.884813 Z" id="Amazon-API-Gateway_Icon_16_Squid" fill="#FFFFFF"></path>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -1,199 +0,0 @@
{
"id": "api-gateway",
"title": "API Gateway",
"icon": "file://icon.svg",
"overview": "file://overview.md",
"supported_signals": {
"metrics": true,
"logs": true
},
"data_collected": {
"metrics": [
{
"name": "aws_ApiGateway_4XXError_count",
"unit": "Count",
"type": "Gauge"
},
{
"name": "aws_ApiGateway_4XXError_max",
"unit": "Count",
"type": "Gauge"
},
{
"name": "aws_ApiGateway_4XXError_min",
"unit": "Count",
"type": "Gauge"
},
{
"name": "aws_ApiGateway_4XXError_sum",
"unit": "Count",
"type": "Gauge"
},
{
"name": "aws_ApiGateway_5XXError_count",
"unit": "Count",
"type": "Gauge"
},
{
"name": "aws_ApiGateway_5XXError_max",
"unit": "Count",
"type": "Gauge"
},
{
"name": "aws_ApiGateway_5XXError_min",
"unit": "Count",
"type": "Gauge"
},
{
"name": "aws_ApiGateway_5XXError_sum",
"unit": "Count",
"type": "Gauge"
},
{
"name": "aws_ApiGateway_CacheHitCount_count",
"unit": "Count",
"type": "Gauge"
},
{
"name": "aws_ApiGateway_CacheHitCount_max",
"unit": "Count",
"type": "Gauge"
},
{
"name": "aws_ApiGateway_CacheHitCount_min",
"unit": "Count",
"type": "Gauge"
},
{
"name": "aws_ApiGateway_CacheHitCount_sum",
"unit": "Count",
"type": "Gauge"
},
{
"name": "aws_ApiGateway_CacheMissCount_count",
"unit": "Count",
"type": "Gauge"
},
{
"name": "aws_ApiGateway_CacheMissCount_max",
"unit": "Count",
"type": "Gauge"
},
{
"name": "aws_ApiGateway_CacheMissCount_min",
"unit": "Count",
"type": "Gauge"
},
{
"name": "aws_ApiGateway_CacheMissCount_sum",
"unit": "Count",
"type": "Gauge"
},
{
"name": "aws_ApiGateway_Count_count",
"unit": "Count",
"type": "Gauge"
},
{
"name": "aws_ApiGateway_Count_max",
"unit": "Count",
"type": "Gauge"
},
{
"name": "aws_ApiGateway_Count_min",
"unit": "Count",
"type": "Gauge"
},
{
"name": "aws_ApiGateway_Count_sum",
"unit": "Count",
"type": "Gauge"
},
{
"name": "aws_ApiGateway_IntegrationLatency_count",
"unit": "Milliseconds",
"type": "Gauge"
},
{
"name": "aws_ApiGateway_IntegrationLatency_max",
"unit": "Milliseconds",
"type": "Gauge"
},
{
"name": "aws_ApiGateway_IntegrationLatency_min",
"unit": "Milliseconds",
"type": "Gauge"
},
{
"name": "aws_ApiGateway_IntegrationLatency_sum",
"unit": "Milliseconds",
"type": "Gauge"
},
{
"name": "aws_ApiGateway_Latency_count",
"unit": "Milliseconds",
"type": "Gauge"
},
{
"name": "aws_ApiGateway_Latency_max",
"unit": "Milliseconds",
"type": "Gauge"
},
{
"name": "aws_ApiGateway_Latency_min",
"unit": "Milliseconds",
"type": "Gauge"
},
{
"name": "aws_ApiGateway_Latency_sum",
"unit": "Milliseconds",
"type": "Gauge"
}
],
"logs": [
{
"name": "Account Id",
"path": "resources.cloud.account.id",
"type": "string"
},
{
"name": "Log Group Name",
"path": "resources.aws.cloudwatch.log_group_name",
"type": "string"
},
{
"name": "Log Stream Name",
"path": "resources.aws.cloudwatch.log_stream_name",
"type": "string"
}
]
},
"telemetry_collection_strategy": {
"aws_metrics": {
"cloudwatch_metric_stream_filters": [
{
"Namespace": "AWS/ApiGateway"
}
]
},
"aws_logs": {
"cloudwatch_logs_subscriptions": [
{
"log_group_name_prefix": "API-Gateway",
"filter_pattern": ""
}
]
}
},
"assets": {
"dashboards": [
{
"id": "overview",
"title": "API Gateway Overview",
"description": "Overview of API Gateway",
"image": "file://assets/dashboards/overview.png",
"definition": "file://assets/dashboards/overview.json"
}
]
}
}

View File

@@ -1,3 +0,0 @@
### Monitor API Gateway with SigNoz
Collect key API Gateway metrics and view them with an out of the box dashboard.

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 6.2 KiB

View File

@@ -1,394 +0,0 @@
{
"id": "dynamodb",
"title": "DynamoDB",
"icon": "file://icon.svg",
"overview": "file://overview.md",
"supported_signals": {
"metrics": true,
"logs": false
},
"data_collected": {
"metrics": [
{
"name": "aws_DynamoDB_AccountMaxReads_count",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_DynamoDB_AccountMaxReads_max",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_DynamoDB_AccountMaxReads_min",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_DynamoDB_AccountMaxReads_sum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_DynamoDB_AccountMaxTableLevelReads_count",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_DynamoDB_AccountMaxTableLevelReads_max",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_DynamoDB_AccountMaxTableLevelReads_min",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_DynamoDB_AccountMaxTableLevelReads_sum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_DynamoDB_AccountMaxTableLevelWrites_count",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_DynamoDB_AccountMaxTableLevelWrites_max",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_DynamoDB_AccountMaxTableLevelWrites_min",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_DynamoDB_AccountMaxTableLevelWrites_sum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_DynamoDB_AccountMaxWrites_count",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_DynamoDB_AccountMaxWrites_max",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_DynamoDB_AccountMaxWrites_min",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_DynamoDB_AccountMaxWrites_sum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_DynamoDB_AccountProvisionedReadCapacityUtilization_count",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "aws_DynamoDB_AccountProvisionedReadCapacityUtilization_max",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "aws_DynamoDB_AccountProvisionedReadCapacityUtilization_min",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "aws_DynamoDB_AccountProvisionedReadCapacityUtilization_sum",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "aws_DynamoDB_AccountProvisionedWriteCapacityUtilization_count",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "aws_DynamoDB_AccountProvisionedWriteCapacityUtilization_max",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "aws_DynamoDB_AccountProvisionedWriteCapacityUtilization_min",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "aws_DynamoDB_AccountProvisionedWriteCapacityUtilization_sum",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "aws_DynamoDB_ConsumedReadCapacityUnits_count",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_DynamoDB_ConsumedReadCapacityUnits_max",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_DynamoDB_ConsumedReadCapacityUnits_min",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_DynamoDB_ConsumedReadCapacityUnits_sum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_DynamoDB_ConsumedWriteCapacityUnits_count",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_DynamoDB_ConsumedWriteCapacityUnits_max",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_DynamoDB_ConsumedWriteCapacityUnits_min",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_DynamoDB_ConsumedWriteCapacityUnits_sum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_DynamoDB_MaxProvisionedTableReadCapacityUtilization_count",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "aws_DynamoDB_MaxProvisionedTableReadCapacityUtilization_max",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "aws_DynamoDB_MaxProvisionedTableReadCapacityUtilization_min",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "aws_DynamoDB_MaxProvisionedTableReadCapacityUtilization_sum",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "aws_DynamoDB_MaxProvisionedTableWriteCapacityUtilization_count",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "aws_DynamoDB_MaxProvisionedTableWriteCapacityUtilization_max",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "aws_DynamoDB_MaxProvisionedTableWriteCapacityUtilization_min",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "aws_DynamoDB_MaxProvisionedTableWriteCapacityUtilization_sum",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "aws_DynamoDB_ReturnedItemCount_count",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_DynamoDB_ReturnedItemCount_max",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_DynamoDB_ReturnedItemCount_min",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_DynamoDB_ReturnedItemCount_sum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_DynamoDB_SuccessfulRequestLatency_count",
"unit": "Milliseconds",
"type": "Gauge",
"description": ""
},
{
"name": "aws_DynamoDB_SuccessfulRequestLatency_max",
"unit": "Milliseconds",
"type": "Gauge",
"description": ""
},
{
"name": "aws_DynamoDB_SuccessfulRequestLatency_min",
"unit": "Milliseconds",
"type": "Gauge",
"description": ""
},
{
"name": "aws_DynamoDB_SuccessfulRequestLatency_sum",
"unit": "Milliseconds",
"type": "Gauge",
"description": ""
},
{
"name": "aws_DynamoDB_ThrottledRequests_count",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_DynamoDB_ThrottledRequests_max",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_DynamoDB_ThrottledRequests_min",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_DynamoDB_ThrottledRequests_sum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_DynamoDB_UserErrors_count",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_DynamoDB_UserErrors_max",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_DynamoDB_UserErrors_min",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_DynamoDB_UserErrors_sum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_DynamoDB_WriteThrottleEvents_count",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_DynamoDB_WriteThrottleEvents_max",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_DynamoDB_WriteThrottleEvents_min",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_DynamoDB_WriteThrottleEvents_sum",
"unit": "Count",
"type": "Gauge",
"description": ""
}
]
},
"telemetry_collection_strategy": {
"aws_metrics": {
"cloudwatch_metric_stream_filters": [
{
"Namespace": "AWS/DynamoDB"
}
]
}
},
"assets": {
"dashboards": [
{
"id": "overview",
"title": "DynamoDB Overview",
"description": "Overview of DynamoDB",
"image": "file://assets/dashboards/overview.png",
"definition": "file://assets/dashboards/overview.json"
}
]
}
}

View File

@@ -1,3 +0,0 @@
### Monitor DynamoDB with SigNoz
Collect DynamoDB Key Metrics and view them with an out of the box dashboard.

View File

@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<svg width="800px" height="800px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="none">
<path fill="#9D5025" d="M1.702 2.98L1 3.312v9.376l.702.332 2.842-4.777L1.702 2.98z" />
<path fill="#F58536" d="M3.339 12.657l-1.637.363V2.98l1.637.353v9.324z" />
<path fill="#9D5025" d="M2.476 2.612l.863-.406 4.096 6.216-4.096 5.372-.863-.406V2.612z" />
<path fill="#F58536" d="M5.38 13.248l-2.041.546V2.206l2.04.548v10.494z" />
<path fill="#9D5025" d="M4.3 1.75l1.08-.512 6.043 7.864-6.043 5.66-1.08-.511V1.749z" />
<path fill="#F58536" d="M7.998 13.856l-2.618.906V1.238l2.618.908v11.71z" />
<path fill="#9D5025" d="M6.602.66L7.998 0l6.538 8.453L7.998 16l-1.396-.66V.66z" />
<path fill="#F58536" d="M15 12.686L7.998 16V0L15 3.314v9.372z" />
</svg>

Before

Width:  |  Height:  |  Size: 805 B

View File

@@ -1,518 +0,0 @@
{
"id": "ec2",
"title": "EC2",
"icon": "file://icon.svg",
"overview": "file://overview.md",
"supported_signals": {
"metrics": true,
"logs": false
},
"data_collected": {
"metrics": [
{
"name": "aws_EC2_CPUCreditBalance_count",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_EC2_CPUCreditBalance_max",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_EC2_CPUCreditBalance_min",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_EC2_CPUCreditBalance_sum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_EC2_CPUCreditUsage_count",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_EC2_CPUCreditUsage_max",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_EC2_CPUCreditUsage_min",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_EC2_CPUCreditUsage_sum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_EC2_CPUSurplusCreditBalance_count",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_EC2_CPUSurplusCreditBalance_max",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_EC2_CPUSurplusCreditBalance_min",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_EC2_CPUSurplusCreditBalance_sum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_EC2_CPUSurplusCreditsCharged_count",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_EC2_CPUSurplusCreditsCharged_max",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_EC2_CPUSurplusCreditsCharged_min",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_EC2_CPUSurplusCreditsCharged_sum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_EC2_CPUUtilization_count",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "aws_EC2_CPUUtilization_max",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "aws_EC2_CPUUtilization_min",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "aws_EC2_CPUUtilization_sum",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "aws_EC2_EBSByteBalance__count",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "aws_EC2_EBSByteBalance__max",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "aws_EC2_EBSByteBalance__min",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "aws_EC2_EBSByteBalance__sum",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "aws_EC2_EBSIOBalance__count",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "aws_EC2_EBSIOBalance__max",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "aws_EC2_EBSIOBalance__min",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "aws_EC2_EBSIOBalance__sum",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "aws_EC2_EBSReadBytes_count",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "aws_EC2_EBSReadBytes_max",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "aws_EC2_EBSReadBytes_min",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "aws_EC2_EBSReadBytes_sum",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "aws_EC2_EBSReadOps_count",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_EC2_EBSReadOps_max",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_EC2_EBSReadOps_min",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_EC2_EBSReadOps_sum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_EC2_EBSWriteBytes_count",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "aws_EC2_EBSWriteBytes_max",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "aws_EC2_EBSWriteBytes_min",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "aws_EC2_EBSWriteBytes_sum",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "aws_EC2_EBSWriteOps_count",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_EC2_EBSWriteOps_max",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_EC2_EBSWriteOps_min",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_EC2_EBSWriteOps_sum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_EC2_MetadataNoToken_count",
"unit": "None",
"type": "Gauge",
"description": ""
},
{
"name": "aws_EC2_MetadataNoToken_max",
"unit": "None",
"type": "Gauge",
"description": ""
},
{
"name": "aws_EC2_MetadataNoToken_min",
"unit": "None",
"type": "Gauge",
"description": ""
},
{
"name": "aws_EC2_MetadataNoToken_sum",
"unit": "None",
"type": "Gauge",
"description": ""
},
{
"name": "aws_EC2_NetworkIn_count",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "aws_EC2_NetworkIn_max",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "aws_EC2_NetworkIn_min",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "aws_EC2_NetworkIn_sum",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "aws_EC2_NetworkOut_count",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "aws_EC2_NetworkOut_max",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "aws_EC2_NetworkOut_min",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "aws_EC2_NetworkOut_sum",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "aws_EC2_NetworkPacketsIn_count",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_EC2_NetworkPacketsIn_max",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_EC2_NetworkPacketsIn_min",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_EC2_NetworkPacketsIn_sum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_EC2_NetworkPacketsOut_count",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_EC2_NetworkPacketsOut_max",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_EC2_NetworkPacketsOut_min",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_EC2_NetworkPacketsOut_sum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_EC2_StatusCheckFailed_AttachedEBS_count",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_EC2_StatusCheckFailed_AttachedEBS_max",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_EC2_StatusCheckFailed_AttachedEBS_min",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_EC2_StatusCheckFailed_AttachedEBS_sum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_EC2_StatusCheckFailed_Instance_count",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_EC2_StatusCheckFailed_Instance_max",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_EC2_StatusCheckFailed_Instance_min",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_EC2_StatusCheckFailed_Instance_sum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_EC2_StatusCheckFailed_System_count",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_EC2_StatusCheckFailed_System_max",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_EC2_StatusCheckFailed_System_min",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_EC2_StatusCheckFailed_System_sum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_EC2_StatusCheckFailed_count",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_EC2_StatusCheckFailed_max",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_EC2_StatusCheckFailed_min",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_EC2_StatusCheckFailed_sum",
"unit": "Count",
"type": "Gauge",
"description": ""
}
],
"logs": []
},
"telemetry_collection_strategy": {
"aws_metrics": {
"cloudwatch_metric_stream_filters": [
{
"Namespace": "AWS/EC2"
},
{
"Namespace": "CWAgent"
}
]
}
},
"assets": {
"dashboards": [
{
"id": "overview",
"title": "EC2 Overview",
"description": "Overview of EC2",
"image": "file://assets/dashboards/overview.png",
"definition": "file://assets/dashboards/overview.json"
}
]
}
}

View File

@@ -1,3 +0,0 @@
### Monitor EC2 with SigNoz
Collect key EC2 metrics and view them with an out of the box dashboard.

View File

@@ -1,851 +0,0 @@
{
"description": "View key AWS ECS metrics with an out of the box dashboard.\n",
"image":"data:image/svg+xml,%3C%3Fxml%20version%3D%221.0%22%20encoding%3D%22UTF-8%22%3F%3E%3Csvg%20width%3D%2280px%22%20height%3D%2280px%22%20viewBox%3D%220%200%2080%2080%22%20version%3D%221.1%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20xmlns%3Axlink%3D%22http%3A%2F%2Fwww.w3.org%2F1999%2Fxlink%22%3E%3C!--%20Generator%3A%20Sketch%2064%20(93537)%20-%20https%3A%2F%2Fsketch.com%20--%3E%3Ctitle%3EIcon-Architecture%2F64%2FArch_Amazon-Elastic-Container-Service_64%3C%2Ftitle%3E%3Cdesc%3ECreated%20with%20Sketch.%3C%2Fdesc%3E%3Cdefs%3E%3ClinearGradient%20x1%3D%220%25%22%20y1%3D%22100%25%22%20x2%3D%22100%25%22%20y2%3D%220%25%22%20id%3D%22linearGradient-1%22%3E%3Cstop%20stop-color%3D%22%23C8511B%22%20offset%3D%220%25%22%3E%3C%2Fstop%3E%3Cstop%20stop-color%3D%22%23FF9900%22%20offset%3D%22100%25%22%3E%3C%2Fstop%3E%3C%2FlinearGradient%3E%3C%2Fdefs%3E%3Cg%20id%3D%22Icon-Architecture%2F64%2FArch_Amazon-Elastic-Container-Service_64%22%20stroke%3D%22none%22%20stroke-width%3D%221%22%20fill%3D%22none%22%20fill-rule%3D%22evenodd%22%3E%3Cg%20id%3D%22Icon-Architecture-BG%2F64%2FContainers%22%20fill%3D%22url(%23linearGradient-1)%22%3E%3Crect%20id%3D%22Rectangle%22%20x%3D%220%22%20y%3D%220%22%20width%3D%2280%22%20height%3D%2280%22%3E%3C%2Frect%3E%3C%2Fg%3E%3Cpath%20d%3D%22M64%2C48.2340095%20L56%2C43.4330117%20L56%2C32.0000169%20C56%2C31.6440171%2055.812%2C31.3150172%2055.504%2C31.1360173%20L44%2C24.4260204%20L44%2C14.7520248%20L64%2C26.5710194%20L64%2C48.2340095%20Z%20M65.509%2C25.13902%20L43.509%2C12.139026%20C43.199%2C11.9560261%2042.818%2C11.9540261%2042.504%2C12.131026%20C42.193%2C12.3090259%2042%2C12.6410257%2042%2C13.0000256%20L42%2C25.0000201%20C42%2C25.3550199%2042.189%2C25.6840198%2042.496%2C25.8640197%20L54%2C32.5740166%20L54%2C44.0000114%20C54%2C44.3510113%2054.185%2C44.6770111%2054.486%2C44.857011%20L64.486%2C50.8570083%20C64.644%2C50.9520082%2064.822%2C51%2065%2C51%20C65.17%2C51%2065.34%2C50.9570082%2065.493%2C50.8700083%20C65.807%2C50.6930084%2066%2C50.3600085%2066%2C50%20L66%2C26.0000196%20C66%2C25.6460198%2065.814%2C25.31902%2065.509%2C25.13902%20L65.509%2C25.13902%20Z%20M40.445%2C66.863001%20L17%2C54.3990067%20L17%2C26.5710194%20L37%2C14.7520248%20L37%2C24.4510204%20L26.463%2C31.1560173%20C26.175%2C31.3400172%2026%2C31.6580171%2026%2C32.0000169%20L26%2C49.0000091%20C26%2C49.373009%2026.208%2C49.7150088%2026.538%2C49.8870087%20L39.991%2C56.8870055%20C40.28%2C57.0370055%2040.624%2C57.0380055%2040.912%2C56.8880055%20L53.964%2C50.1440086%20L61.996%2C54.9640064%20L40.445%2C66.863001%20Z%20M64.515%2C54.1420068%20L54.515%2C48.1420095%20C54.217%2C47.9640096%2053.849%2C47.9520096%2053.541%2C48.1120095%20L40.455%2C54.8730065%20L28%2C48.3930094%20L28%2C32.5490167%20L38.537%2C25.8440197%20C38.825%2C25.6600198%2039%2C25.3420199%2039%2C25.0000201%20L39%2C13.0000256%20C39%2C12.6410257%2038.808%2C12.3090259%2038.496%2C12.131026%20C38.184%2C11.9540261%2037.802%2C11.9560261%2037.491%2C12.139026%20L15.491%2C25.13902%20C15.187%2C25.31902%2015%2C25.6460198%2015%2C26.0000196%20L15%2C55%20C15%2C55.3690062%2015.204%2C55.7090061%2015.53%2C55.883006%20L39.984%2C68.8830001%20C40.131%2C68.961%2040.292%2C69%2040.453%2C69%20C40.62%2C69%2040.786%2C68.958%2040.937%2C68.8750001%20L64.484%2C55.875006%20C64.797%2C55.7020061%2064.993%2C55.3750062%2065.0001416%2C55.0180064%20C65.006%2C54.6600066%2064.821%2C54.3260067%2064.515%2C54.1420068%20L64.515%2C54.1420068%20Z%22%20id%3D%22Amazon-Elastic-Container-Service_Icon_64_Squid%22%20fill%3D%22%23FFFFFF%22%3E%3C%2Fpath%3E%3C%2Fg%3E%3C%2Fsvg%3E",
"layout": [
{
"h": 6,
"i": "f78becf8-0328-48b4-84b6-ff4dac325940",
"moved": false,
"static": false,
"w": 6,
"x": 0,
"y": 0
},
{
"h": 6,
"i": "2b4eac06-b426-4f78-b874-2e1734c4104b",
"moved": false,
"static": false,
"w": 6,
"x": 6,
"y": 0
},
{
"h": 6,
"i": "5bea2bc0-13a2-4937-bccb-60ffe8a43ad5",
"moved": false,
"static": false,
"w": 6,
"x": 0,
"y": 6
},
{
"h": 6,
"i": "6fac67b0-50ec-4b43-ac4b-320a303d0369",
"moved": false,
"static": false,
"w": 6,
"x": 6,
"y": 6
}
],
"panelMap": {},
"tags": [],
"title": "AWS ECS Overview",
"uploadedGrafana": false,
"variables": {
"51f4fa2b-89c7-47c2-9795-f32cffaab985": {
"allSelected": false,
"customValue": "",
"description": "AWS Account ID",
"id": "51f4fa2b-89c7-47c2-9795-f32cffaab985",
"key": "51f4fa2b-89c7-47c2-9795-f32cffaab985",
"modificationUUID": "7b814d17-8fff-4ed6-a4ea-90e3b1a97584",
"multiSelect": false,
"name": "Account",
"order": 0,
"queryValue": "SELECT DISTINCT JSONExtractString(labels, 'cloud_account_id') AS cloud_account_id\nFROM signoz_metrics.distributed_time_series_v4_1day\nWHERE metric_name = 'aws_ECS_MemoryUtilization_max' GROUP BY cloud_account_id",
"showALLOption": false,
"sort": "DISABLED",
"textboxValue": "",
"type": "QUERY"
},
"9faf0f4b-b245-4b3c-83a3-60cfa76dfeb0": {
"allSelected": false,
"customValue": "",
"description": "Account Region",
"id": "9faf0f4b-b245-4b3c-83a3-60cfa76dfeb0",
"key": "9faf0f4b-b245-4b3c-83a3-60cfa76dfeb0",
"modificationUUID": "3b5f499b-22a3-4c8a-847c-8d3811c9e6b2",
"multiSelect": false,
"name": "Region",
"order": 1,
"queryValue": "SELECT DISTINCT JSONExtractString(labels, 'cloud_region') AS region\nFROM signoz_metrics.distributed_time_series_v4_1day\nWHERE metric_name = 'aws_ECS_MemoryUtilization_max' AND JSONExtractString(labels, 'cloud_account_id') IN {{.Account}} GROUP BY region",
"showALLOption": false,
"sort": "ASC",
"textboxValue": "",
"type": "QUERY"
},
"bfbdbcbe-a168-4d81-b108-36339e249116": {
"allSelected": true,
"customValue": "",
"description": "ECS Cluster Name",
"id": "bfbdbcbe-a168-4d81-b108-36339e249116",
"key": "bfbdbcbe-a168-4d81-b108-36339e249116",
"modificationUUID": "9fb0d63c-ac6c-497d-82b3-17d95944e245",
"multiSelect": true,
"name": "Cluster",
"order": 2,
"queryValue": "SELECT DISTINCT JSONExtractString(labels, 'ClusterName') AS cluster\nFROM signoz_metrics.distributed_time_series_v4_1day\nWHERE metric_name = 'aws_ECS_MemoryUtilization_max' AND JSONExtractString(labels, 'cloud_account_id') IN {{.Account}} AND JSONExtractString(labels, 'cloud_region') IN {{.Region}}\nGROUP BY cluster",
"showALLOption": true,
"sort": "ASC",
"textboxValue": "",
"type": "QUERY"
}
},
"version": "v4",
"widgets": [
{
"bucketCount": 30,
"bucketWidth": 0,
"columnUnits": {},
"description": "",
"fillSpans": false,
"id": "f78becf8-0328-48b4-84b6-ff4dac325940",
"isLogScale": false,
"isStacked": false,
"mergeAllActiveQueries": false,
"nullZeroValues": "zero",
"opacity": "1",
"panelTypes": "graph",
"query": {
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "float64",
"id": "aws_ECS_MemoryUtilization_max--float64--Gauge--true",
"isColumn": true,
"isJSON": false,
"key": "aws_ECS_MemoryUtilization_max",
"type": "Gauge"
},
"aggregateOperator": "max",
"dataSource": "metrics",
"disabled": false,
"expression": "A",
"filters": {
"items": [
{
"id": "26ac617d",
"key": {
"dataType": "string",
"id": "cloud_region--string--tag--false",
"isColumn": false,
"isJSON": false,
"key": "cloud_region",
"type": "tag"
},
"op": "=",
"value": "$Region"
},
{
"id": "57172ed9",
"key": {
"dataType": "string",
"id": "cloud_account_id--string--tag--false",
"isColumn": false,
"isJSON": false,
"key": "cloud_account_id",
"type": "tag"
},
"op": "=",
"value": "$Account"
},
{
"id": "49b9f85e",
"key": {
"dataType": "string",
"id": "ClusterName--string--tag--false",
"isColumn": false,
"isJSON": false,
"key": "ClusterName",
"type": "tag"
},
"op": "in",
"value": [
"$Cluster"
]
}
],
"op": "AND"
},
"functions": [],
"groupBy": [
{
"dataType": "string",
"id": "ServiceName--string--tag--false",
"isColumn": false,
"isJSON": false,
"key": "ServiceName",
"type": "tag"
},
{
"dataType": "string",
"id": "ClusterName--string--tag--false",
"isColumn": false,
"isJSON": false,
"key": "ClusterName",
"type": "tag"
}
],
"having": [],
"legend": "{{ServiceName}} ({{ClusterName}})",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"spaceAggregation": "max",
"stepInterval": 60,
"timeAggregation": "max"
}
],
"queryFormulas": []
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": ""
}
],
"id": "56068fdd-d523-4117-92fa-87c6518ad07c",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": ""
}
],
"queryType": "builder"
},
"selectedLogFields": [
{
"dataType": "string",
"name": "body",
"type": ""
},
{
"dataType": "string",
"name": "timestamp",
"type": ""
}
],
"selectedTracesFields": [
{
"dataType": "string",
"id": "serviceName--string--tag--true",
"isColumn": true,
"isJSON": false,
"key": "serviceName",
"type": "tag"
},
{
"dataType": "string",
"id": "name--string--tag--true",
"isColumn": true,
"isJSON": false,
"key": "name",
"type": "tag"
},
{
"dataType": "float64",
"id": "durationNano--float64--tag--true",
"isColumn": true,
"isJSON": false,
"key": "durationNano",
"type": "tag"
},
{
"dataType": "string",
"id": "httpMethod--string--tag--true",
"isColumn": true,
"isJSON": false,
"key": "httpMethod",
"type": "tag"
},
{
"dataType": "string",
"id": "responseStatusCode--string--tag--true",
"isColumn": true,
"isJSON": false,
"key": "responseStatusCode",
"type": "tag"
}
],
"softMax": 0,
"softMin": 0,
"stackedBarChart": false,
"thresholds": [],
"timePreferance": "GLOBAL_TIME",
"title": "Maximum Memory Utilization",
"yAxisUnit": "none"
},
{
"bucketCount": 30,
"bucketWidth": 0,
"columnUnits": {},
"description": "",
"fillSpans": false,
"id": "2b4eac06-b426-4f78-b874-2e1734c4104b",
"isLogScale": false,
"isStacked": false,
"mergeAllActiveQueries": false,
"nullZeroValues": "zero",
"opacity": "1",
"panelTypes": "graph",
"query": {
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "float64",
"id": "aws_ECS_MemoryUtilization_min--float64--Gauge--true",
"isColumn": true,
"isJSON": false,
"key": "aws_ECS_MemoryUtilization_min",
"type": "Gauge"
},
"aggregateOperator": "min",
"dataSource": "metrics",
"disabled": false,
"expression": "A",
"filters": {
"items": [
{
"id": "cd4b8848",
"key": {
"dataType": "string",
"id": "cloud_region--string--tag--false",
"isColumn": false,
"isJSON": false,
"key": "cloud_region",
"type": "tag"
},
"op": "=",
"value": "$Region"
},
{
"id": "aa5115c6",
"key": {
"dataType": "string",
"id": "cloud_account_id--string--tag--false",
"isColumn": false,
"isJSON": false,
"key": "cloud_account_id",
"type": "tag"
},
"op": "=",
"value": "$Account"
},
{
"id": "f60677b6",
"key": {
"dataType": "string",
"id": "ClusterName--string--tag--false",
"isColumn": false,
"isJSON": false,
"key": "ClusterName",
"type": "tag"
},
"op": "in",
"value": [
"$Cluster"
]
}
],
"op": "AND"
},
"functions": [],
"groupBy": [
{
"dataType": "string",
"id": "ServiceName--string--tag--false",
"isColumn": false,
"isJSON": false,
"key": "ServiceName",
"type": "tag"
},
{
"dataType": "string",
"id": "ClusterName--string--tag--false",
"isColumn": false,
"isJSON": false,
"key": "ClusterName",
"type": "tag"
}
],
"having": [],
"legend": "{{ServiceName}} ({{ClusterName}})",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"spaceAggregation": "min",
"stepInterval": 60,
"timeAggregation": "min"
}
],
"queryFormulas": []
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": ""
}
],
"id": "fb19342e-cbde-40d8-b12f-ad108698356b",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": ""
}
],
"queryType": "builder"
},
"selectedLogFields": [
{
"dataType": "string",
"name": "body",
"type": ""
},
{
"dataType": "string",
"name": "timestamp",
"type": ""
}
],
"selectedTracesFields": [
{
"dataType": "string",
"id": "serviceName--string--tag--true",
"isColumn": true,
"isJSON": false,
"key": "serviceName",
"type": "tag"
},
{
"dataType": "string",
"id": "name--string--tag--true",
"isColumn": true,
"isJSON": false,
"key": "name",
"type": "tag"
},
{
"dataType": "float64",
"id": "durationNano--float64--tag--true",
"isColumn": true,
"isJSON": false,
"key": "durationNano",
"type": "tag"
},
{
"dataType": "string",
"id": "httpMethod--string--tag--true",
"isColumn": true,
"isJSON": false,
"key": "httpMethod",
"type": "tag"
},
{
"dataType": "string",
"id": "responseStatusCode--string--tag--true",
"isColumn": true,
"isJSON": false,
"key": "responseStatusCode",
"type": "tag"
}
],
"softMax": 0,
"softMin": 0,
"stackedBarChart": false,
"thresholds": [],
"timePreferance": "GLOBAL_TIME",
"title": "Minimum Memory Utilization",
"yAxisUnit": "none"
},
{
"bucketCount": 30,
"bucketWidth": 0,
"columnUnits": {},
"description": "",
"fillSpans": false,
"id": "5bea2bc0-13a2-4937-bccb-60ffe8a43ad5",
"isLogScale": false,
"isStacked": false,
"mergeAllActiveQueries": false,
"nullZeroValues": "zero",
"opacity": "1",
"panelTypes": "graph",
"query": {
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "float64",
"id": "aws_ECS_CPUUtilization_max--float64--Gauge--true",
"isColumn": true,
"isJSON": false,
"key": "aws_ECS_CPUUtilization_max",
"type": "Gauge"
},
"aggregateOperator": "max",
"dataSource": "metrics",
"disabled": false,
"expression": "A",
"filters": {
"items": [
{
"id": "2c13c8ee",
"key": {
"dataType": "string",
"id": "cloud_region--string--tag--false",
"isColumn": false,
"isJSON": false,
"key": "cloud_region",
"type": "tag"
},
"op": "=",
"value": "$Region"
},
{
"id": "f489f6a8",
"key": {
"dataType": "string",
"id": "cloud_account_id--string--tag--false",
"isColumn": false,
"isJSON": false,
"key": "cloud_account_id",
"type": "tag"
},
"op": "=",
"value": "$Account"
},
{
"id": "94012320",
"key": {
"dataType": "string",
"id": "ClusterName--string--tag--false",
"isColumn": false,
"isJSON": false,
"key": "ClusterName",
"type": "tag"
},
"op": "in",
"value": [
"$Cluster"
]
}
],
"op": "AND"
},
"functions": [],
"groupBy": [
{
"dataType": "string",
"id": "ServiceName--string--tag--false",
"isColumn": false,
"isJSON": false,
"key": "ServiceName",
"type": "tag"
},
{
"dataType": "string",
"id": "ClusterName--string--tag--false",
"isColumn": false,
"isJSON": false,
"key": "ClusterName",
"type": "tag"
}
],
"having": [],
"legend": "{{ServiceName}} ({{ClusterName}})",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"spaceAggregation": "max",
"stepInterval": 60,
"timeAggregation": "max"
}
],
"queryFormulas": []
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": ""
}
],
"id": "273e0a76-c780-4b9a-9b03-2649d4227173",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": ""
}
],
"queryType": "builder"
},
"selectedLogFields": [
{
"dataType": "string",
"name": "body",
"type": ""
},
{
"dataType": "string",
"name": "timestamp",
"type": ""
}
],
"selectedTracesFields": [
{
"dataType": "string",
"id": "serviceName--string--tag--true",
"isColumn": true,
"isJSON": false,
"key": "serviceName",
"type": "tag"
},
{
"dataType": "string",
"id": "name--string--tag--true",
"isColumn": true,
"isJSON": false,
"key": "name",
"type": "tag"
},
{
"dataType": "float64",
"id": "durationNano--float64--tag--true",
"isColumn": true,
"isJSON": false,
"key": "durationNano",
"type": "tag"
},
{
"dataType": "string",
"id": "httpMethod--string--tag--true",
"isColumn": true,
"isJSON": false,
"key": "httpMethod",
"type": "tag"
},
{
"dataType": "string",
"id": "responseStatusCode--string--tag--true",
"isColumn": true,
"isJSON": false,
"key": "responseStatusCode",
"type": "tag"
}
],
"softMax": 0,
"softMin": 0,
"stackedBarChart": false,
"thresholds": [],
"timePreferance": "GLOBAL_TIME",
"title": "Maximum CPU Utilization",
"yAxisUnit": "none"
},
{
"bucketCount": 30,
"bucketWidth": 0,
"columnUnits": {},
"description": "",
"fillSpans": false,
"id": "6fac67b0-50ec-4b43-ac4b-320a303d0369",
"isLogScale": false,
"isStacked": false,
"mergeAllActiveQueries": false,
"nullZeroValues": "zero",
"opacity": "1",
"panelTypes": "graph",
"query": {
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "float64",
"id": "aws_ECS_CPUUtilization_min--float64--Gauge--true",
"isColumn": true,
"isJSON": false,
"key": "aws_ECS_CPUUtilization_min",
"type": "Gauge"
},
"aggregateOperator": "min",
"dataSource": "metrics",
"disabled": false,
"expression": "A",
"filters": {
"items": [
{
"id": "758ba906",
"key": {
"dataType": "string",
"id": "cloud_region--string--tag--false",
"isColumn": false,
"isJSON": false,
"key": "cloud_region",
"type": "tag"
},
"op": "=",
"value": "$Region"
},
{
"id": "4ffe6bf7",
"key": {
"dataType": "string",
"id": "cloud_account_id--string--tag--false",
"isColumn": false,
"isJSON": false,
"key": "cloud_account_id",
"type": "tag"
},
"op": "=",
"value": "$Account"
},
{
"id": "53d98059",
"key": {
"dataType": "string",
"id": "ClusterName--string--tag--false",
"isColumn": false,
"isJSON": false,
"key": "ClusterName",
"type": "tag"
},
"op": "in",
"value": [
"$Cluster"
]
}
],
"op": "AND"
},
"functions": [],
"groupBy": [
{
"dataType": "string",
"id": "ServiceName--string--tag--false",
"isColumn": false,
"isJSON": false,
"key": "ServiceName",
"type": "tag"
},
{
"dataType": "string",
"id": "ClusterName--string--tag--false",
"isColumn": false,
"isJSON": false,
"key": "ClusterName",
"type": "tag"
}
],
"having": [],
"legend": "{{ServiceName}} ({{ClusterName}})",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"spaceAggregation": "min",
"stepInterval": 60,
"timeAggregation": "min"
}
],
"queryFormulas": []
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": ""
}
],
"id": "c89482b3-5a98-4e2c-be0d-ef036d7dac05",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": ""
}
],
"queryType": "builder"
},
"selectedLogFields": [
{
"dataType": "string",
"name": "body",
"type": ""
},
{
"dataType": "string",
"name": "timestamp",
"type": ""
}
],
"selectedTracesFields": [
{
"dataType": "string",
"id": "serviceName--string--tag--true",
"isColumn": true,
"isJSON": false,
"key": "serviceName",
"type": "tag"
},
{
"dataType": "string",
"id": "name--string--tag--true",
"isColumn": true,
"isJSON": false,
"key": "name",
"type": "tag"
},
{
"dataType": "float64",
"id": "durationNano--float64--tag--true",
"isColumn": true,
"isJSON": false,
"key": "durationNano",
"type": "tag"
},
{
"dataType": "string",
"id": "httpMethod--string--tag--true",
"isColumn": true,
"isJSON": false,
"key": "httpMethod",
"type": "tag"
},
{
"dataType": "string",
"id": "responseStatusCode--string--tag--true",
"isColumn": true,
"isJSON": false,
"key": "responseStatusCode",
"type": "tag"
}
],
"softMax": 0,
"softMin": 0,
"stackedBarChart": false,
"thresholds": [],
"timePreferance": "GLOBAL_TIME",
"title": "Minimum CPU Utilization",
"yAxisUnit": "none"
}
]
}

View File

@@ -1,851 +0,0 @@
{
"description": "View key AWS ECS metrics with an out of the box dashboard.\n",
"image":"data:image/svg+xml,%3C%3Fxml%20version%3D%221.0%22%20encoding%3D%22UTF-8%22%3F%3E%3Csvg%20width%3D%2280px%22%20height%3D%2280px%22%20viewBox%3D%220%200%2080%2080%22%20version%3D%221.1%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20xmlns%3Axlink%3D%22http%3A%2F%2Fwww.w3.org%2F1999%2Fxlink%22%3E%3C!--%20Generator%3A%20Sketch%2064%20(93537)%20-%20https%3A%2F%2Fsketch.com%20--%3E%3Ctitle%3EIcon-Architecture%2F64%2FArch_Amazon-Elastic-Container-Service_64%3C%2Ftitle%3E%3Cdesc%3ECreated%20with%20Sketch.%3C%2Fdesc%3E%3Cdefs%3E%3ClinearGradient%20x1%3D%220%25%22%20y1%3D%22100%25%22%20x2%3D%22100%25%22%20y2%3D%220%25%22%20id%3D%22linearGradient-1%22%3E%3Cstop%20stop-color%3D%22%23C8511B%22%20offset%3D%220%25%22%3E%3C%2Fstop%3E%3Cstop%20stop-color%3D%22%23FF9900%22%20offset%3D%22100%25%22%3E%3C%2Fstop%3E%3C%2FlinearGradient%3E%3C%2Fdefs%3E%3Cg%20id%3D%22Icon-Architecture%2F64%2FArch_Amazon-Elastic-Container-Service_64%22%20stroke%3D%22none%22%20stroke-width%3D%221%22%20fill%3D%22none%22%20fill-rule%3D%22evenodd%22%3E%3Cg%20id%3D%22Icon-Architecture-BG%2F64%2FContainers%22%20fill%3D%22url(%23linearGradient-1)%22%3E%3Crect%20id%3D%22Rectangle%22%20x%3D%220%22%20y%3D%220%22%20width%3D%2280%22%20height%3D%2280%22%3E%3C%2Frect%3E%3C%2Fg%3E%3Cpath%20d%3D%22M64%2C48.2340095%20L56%2C43.4330117%20L56%2C32.0000169%20C56%2C31.6440171%2055.812%2C31.3150172%2055.504%2C31.1360173%20L44%2C24.4260204%20L44%2C14.7520248%20L64%2C26.5710194%20L64%2C48.2340095%20Z%20M65.509%2C25.13902%20L43.509%2C12.139026%20C43.199%2C11.9560261%2042.818%2C11.9540261%2042.504%2C12.131026%20C42.193%2C12.3090259%2042%2C12.6410257%2042%2C13.0000256%20L42%2C25.0000201%20C42%2C25.3550199%2042.189%2C25.6840198%2042.496%2C25.8640197%20L54%2C32.5740166%20L54%2C44.0000114%20C54%2C44.3510113%2054.185%2C44.6770111%2054.486%2C44.857011%20L64.486%2C50.8570083%20C64.644%2C50.9520082%2064.822%2C51%2065%2C51%20C65.17%2C51%2065.34%2C50.9570082%2065.493%2C50.8700083%20C65.807%2C50.6930084%2066%2C50.3600085%2066%2C50%20L66%2C26.0000196%20C66%2C25.6460198%2065.814%2C25.31902%2065.509%2C25.13902%20L65.509%2C25.13902%20Z%20M40.445%2C66.863001%20L17%2C54.3990067%20L17%2C26.5710194%20L37%2C14.7520248%20L37%2C24.4510204%20L26.463%2C31.1560173%20C26.175%2C31.3400172%2026%2C31.6580171%2026%2C32.0000169%20L26%2C49.0000091%20C26%2C49.373009%2026.208%2C49.7150088%2026.538%2C49.8870087%20L39.991%2C56.8870055%20C40.28%2C57.0370055%2040.624%2C57.0380055%2040.912%2C56.8880055%20L53.964%2C50.1440086%20L61.996%2C54.9640064%20L40.445%2C66.863001%20Z%20M64.515%2C54.1420068%20L54.515%2C48.1420095%20C54.217%2C47.9640096%2053.849%2C47.9520096%2053.541%2C48.1120095%20L40.455%2C54.8730065%20L28%2C48.3930094%20L28%2C32.5490167%20L38.537%2C25.8440197%20C38.825%2C25.6600198%2039%2C25.3420199%2039%2C25.0000201%20L39%2C13.0000256%20C39%2C12.6410257%2038.808%2C12.3090259%2038.496%2C12.131026%20C38.184%2C11.9540261%2037.802%2C11.9560261%2037.491%2C12.139026%20L15.491%2C25.13902%20C15.187%2C25.31902%2015%2C25.6460198%2015%2C26.0000196%20L15%2C55%20C15%2C55.3690062%2015.204%2C55.7090061%2015.53%2C55.883006%20L39.984%2C68.8830001%20C40.131%2C68.961%2040.292%2C69%2040.453%2C69%20C40.62%2C69%2040.786%2C68.958%2040.937%2C68.8750001%20L64.484%2C55.875006%20C64.797%2C55.7020061%2064.993%2C55.3750062%2065.0001416%2C55.0180064%20C65.006%2C54.6600066%2064.821%2C54.3260067%2064.515%2C54.1420068%20L64.515%2C54.1420068%20Z%22%20id%3D%22Amazon-Elastic-Container-Service_Icon_64_Squid%22%20fill%3D%22%23FFFFFF%22%3E%3C%2Fpath%3E%3C%2Fg%3E%3C%2Fsvg%3E",
"layout": [
{
"h": 6,
"i": "f78becf8-0328-48b4-84b6-ff4dac325940",
"moved": false,
"static": false,
"w": 6,
"x": 0,
"y": 0
},
{
"h": 6,
"i": "2b4eac06-b426-4f78-b874-2e1734c4104b",
"moved": false,
"static": false,
"w": 6,
"x": 6,
"y": 0
},
{
"h": 6,
"i": "5bea2bc0-13a2-4937-bccb-60ffe8a43ad5",
"moved": false,
"static": false,
"w": 6,
"x": 0,
"y": 6
},
{
"h": 6,
"i": "6fac67b0-50ec-4b43-ac4b-320a303d0369",
"moved": false,
"static": false,
"w": 6,
"x": 6,
"y": 6
}
],
"panelMap": {},
"tags": [],
"title": "AWS ECS Overview",
"uploadedGrafana": false,
"variables": {
"51f4fa2b-89c7-47c2-9795-f32cffaab985": {
"allSelected": false,
"customValue": "",
"description": "AWS Account ID",
"id": "51f4fa2b-89c7-47c2-9795-f32cffaab985",
"key": "51f4fa2b-89c7-47c2-9795-f32cffaab985",
"modificationUUID": "7b814d17-8fff-4ed6-a4ea-90e3b1a97584",
"multiSelect": false,
"name": "Account",
"order": 0,
"queryValue": "SELECT DISTINCT JSONExtractString(labels, 'cloud.account.id') AS `cloud.account.id`\nFROM signoz_metrics.distributed_time_series_v4_1day\nWHERE metric_name = 'aws_ECS_MemoryUtilization_max' GROUP BY `cloud.account.id`",
"showALLOption": false,
"sort": "DISABLED",
"textboxValue": "",
"type": "QUERY"
},
"9faf0f4b-b245-4b3c-83a3-60cfa76dfeb0": {
"allSelected": false,
"customValue": "",
"description": "Account Region",
"id": "9faf0f4b-b245-4b3c-83a3-60cfa76dfeb0",
"key": "9faf0f4b-b245-4b3c-83a3-60cfa76dfeb0",
"modificationUUID": "3b5f499b-22a3-4c8a-847c-8d3811c9e6b2",
"multiSelect": false,
"name": "Region",
"order": 1,
"queryValue": "SELECT DISTINCT JSONExtractString(labels, 'cloud.region') AS region\nFROM signoz_metrics.distributed_time_series_v4_1day\nWHERE metric_name = 'aws_ECS_MemoryUtilization_max' AND JSONExtractString(labels, 'cloud.account.id') IN {{.Account}} GROUP BY region",
"showALLOption": false,
"sort": "ASC",
"textboxValue": "",
"type": "QUERY"
},
"bfbdbcbe-a168-4d81-b108-36339e249116": {
"allSelected": true,
"customValue": "",
"description": "ECS Cluster Name",
"id": "bfbdbcbe-a168-4d81-b108-36339e249116",
"key": "bfbdbcbe-a168-4d81-b108-36339e249116",
"modificationUUID": "9fb0d63c-ac6c-497d-82b3-17d95944e245",
"multiSelect": true,
"name": "Cluster",
"order": 2,
"queryValue": "SELECT DISTINCT JSONExtractString(labels, 'ClusterName') AS cluster\nFROM signoz_metrics.distributed_time_series_v4_1day\nWHERE metric_name = 'aws_ECS_MemoryUtilization_max' AND JSONExtractString(labels, 'cloud.account.id') IN {{.Account}} AND JSONExtractString(labels, 'cloud.region') IN {{.Region}}\nGROUP BY cluster",
"showALLOption": true,
"sort": "ASC",
"textboxValue": "",
"type": "QUERY"
}
},
"version": "v4",
"widgets": [
{
"bucketCount": 30,
"bucketWidth": 0,
"columnUnits": {},
"description": "",
"fillSpans": false,
"id": "f78becf8-0328-48b4-84b6-ff4dac325940",
"isLogScale": false,
"isStacked": false,
"mergeAllActiveQueries": false,
"nullZeroValues": "zero",
"opacity": "1",
"panelTypes": "graph",
"query": {
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "float64",
"id": "aws_ECS_MemoryUtilization_max--float64--Gauge--true",
"isColumn": true,
"isJSON": false,
"key": "aws_ECS_MemoryUtilization_max",
"type": "Gauge"
},
"aggregateOperator": "max",
"dataSource": "metrics",
"disabled": false,
"expression": "A",
"filters": {
"items": [
{
"id": "26ac617d",
"key": {
"dataType": "string",
"id": "cloud.region--string--tag--false",
"isColumn": false,
"isJSON": false,
"key": "cloud.region",
"type": "tag"
},
"op": "=",
"value": "$Region"
},
{
"id": "57172ed9",
"key": {
"dataType": "string",
"id": "cloud.account.id--string--tag--false",
"isColumn": false,
"isJSON": false,
"key": "cloud.account.id",
"type": "tag"
},
"op": "=",
"value": "$Account"
},
{
"id": "49b9f85e",
"key": {
"dataType": "string",
"id": "ClusterName--string--tag--false",
"isColumn": false,
"isJSON": false,
"key": "ClusterName",
"type": "tag"
},
"op": "in",
"value": [
"$Cluster"
]
}
],
"op": "AND"
},
"functions": [],
"groupBy": [
{
"dataType": "string",
"id": "ServiceName--string--tag--false",
"isColumn": false,
"isJSON": false,
"key": "ServiceName",
"type": "tag"
},
{
"dataType": "string",
"id": "ClusterName--string--tag--false",
"isColumn": false,
"isJSON": false,
"key": "ClusterName",
"type": "tag"
}
],
"having": [],
"legend": "{{ServiceName}} ({{ClusterName}})",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"spaceAggregation": "max",
"stepInterval": 60,
"timeAggregation": "max"
}
],
"queryFormulas": []
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": ""
}
],
"id": "56068fdd-d523-4117-92fa-87c6518ad07c",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": ""
}
],
"queryType": "builder"
},
"selectedLogFields": [
{
"dataType": "string",
"name": "body",
"type": ""
},
{
"dataType": "string",
"name": "timestamp",
"type": ""
}
],
"selectedTracesFields": [
{
"dataType": "string",
"id": "serviceName--string--tag--true",
"isColumn": true,
"isJSON": false,
"key": "serviceName",
"type": "tag"
},
{
"dataType": "string",
"id": "name--string--tag--true",
"isColumn": true,
"isJSON": false,
"key": "name",
"type": "tag"
},
{
"dataType": "float64",
"id": "durationNano--float64--tag--true",
"isColumn": true,
"isJSON": false,
"key": "durationNano",
"type": "tag"
},
{
"dataType": "string",
"id": "httpMethod--string--tag--true",
"isColumn": true,
"isJSON": false,
"key": "httpMethod",
"type": "tag"
},
{
"dataType": "string",
"id": "responseStatusCode--string--tag--true",
"isColumn": true,
"isJSON": false,
"key": "responseStatusCode",
"type": "tag"
}
],
"softMax": 0,
"softMin": 0,
"stackedBarChart": false,
"thresholds": [],
"timePreferance": "GLOBAL_TIME",
"title": "Maximum Memory Utilization",
"yAxisUnit": "none"
},
{
"bucketCount": 30,
"bucketWidth": 0,
"columnUnits": {},
"description": "",
"fillSpans": false,
"id": "2b4eac06-b426-4f78-b874-2e1734c4104b",
"isLogScale": false,
"isStacked": false,
"mergeAllActiveQueries": false,
"nullZeroValues": "zero",
"opacity": "1",
"panelTypes": "graph",
"query": {
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "float64",
"id": "aws_ECS_MemoryUtilization_min--float64--Gauge--true",
"isColumn": true,
"isJSON": false,
"key": "aws_ECS_MemoryUtilization_min",
"type": "Gauge"
},
"aggregateOperator": "min",
"dataSource": "metrics",
"disabled": false,
"expression": "A",
"filters": {
"items": [
{
"id": "cd4b8848",
"key": {
"dataType": "string",
"id": "cloud.region--string--tag--false",
"isColumn": false,
"isJSON": false,
"key": "cloud.region",
"type": "tag"
},
"op": "=",
"value": "$Region"
},
{
"id": "aa5115c6",
"key": {
"dataType": "string",
"id": "cloud.account.id--string--tag--false",
"isColumn": false,
"isJSON": false,
"key": "cloud.account.id",
"type": "tag"
},
"op": "=",
"value": "$Account"
},
{
"id": "f60677b6",
"key": {
"dataType": "string",
"id": "ClusterName--string--tag--false",
"isColumn": false,
"isJSON": false,
"key": "ClusterName",
"type": "tag"
},
"op": "in",
"value": [
"$Cluster"
]
}
],
"op": "AND"
},
"functions": [],
"groupBy": [
{
"dataType": "string",
"id": "ServiceName--string--tag--false",
"isColumn": false,
"isJSON": false,
"key": "ServiceName",
"type": "tag"
},
{
"dataType": "string",
"id": "ClusterName--string--tag--false",
"isColumn": false,
"isJSON": false,
"key": "ClusterName",
"type": "tag"
}
],
"having": [],
"legend": "{{ServiceName}} ({{ClusterName}})",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"spaceAggregation": "min",
"stepInterval": 60,
"timeAggregation": "min"
}
],
"queryFormulas": []
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": ""
}
],
"id": "fb19342e-cbde-40d8-b12f-ad108698356b",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": ""
}
],
"queryType": "builder"
},
"selectedLogFields": [
{
"dataType": "string",
"name": "body",
"type": ""
},
{
"dataType": "string",
"name": "timestamp",
"type": ""
}
],
"selectedTracesFields": [
{
"dataType": "string",
"id": "serviceName--string--tag--true",
"isColumn": true,
"isJSON": false,
"key": "serviceName",
"type": "tag"
},
{
"dataType": "string",
"id": "name--string--tag--true",
"isColumn": true,
"isJSON": false,
"key": "name",
"type": "tag"
},
{
"dataType": "float64",
"id": "durationNano--float64--tag--true",
"isColumn": true,
"isJSON": false,
"key": "durationNano",
"type": "tag"
},
{
"dataType": "string",
"id": "httpMethod--string--tag--true",
"isColumn": true,
"isJSON": false,
"key": "httpMethod",
"type": "tag"
},
{
"dataType": "string",
"id": "responseStatusCode--string--tag--true",
"isColumn": true,
"isJSON": false,
"key": "responseStatusCode",
"type": "tag"
}
],
"softMax": 0,
"softMin": 0,
"stackedBarChart": false,
"thresholds": [],
"timePreferance": "GLOBAL_TIME",
"title": "Minimum Memory Utilization",
"yAxisUnit": "none"
},
{
"bucketCount": 30,
"bucketWidth": 0,
"columnUnits": {},
"description": "",
"fillSpans": false,
"id": "5bea2bc0-13a2-4937-bccb-60ffe8a43ad5",
"isLogScale": false,
"isStacked": false,
"mergeAllActiveQueries": false,
"nullZeroValues": "zero",
"opacity": "1",
"panelTypes": "graph",
"query": {
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "float64",
"id": "aws_ECS_CPUUtilization_max--float64--Gauge--true",
"isColumn": true,
"isJSON": false,
"key": "aws_ECS_CPUUtilization_max",
"type": "Gauge"
},
"aggregateOperator": "max",
"dataSource": "metrics",
"disabled": false,
"expression": "A",
"filters": {
"items": [
{
"id": "2c13c8ee",
"key": {
"dataType": "string",
"id": "cloud.region--string--tag--false",
"isColumn": false,
"isJSON": false,
"key": "cloud.region",
"type": "tag"
},
"op": "=",
"value": "$Region"
},
{
"id": "f489f6a8",
"key": {
"dataType": "string",
"id": "cloud.account.id--string--tag--false",
"isColumn": false,
"isJSON": false,
"key": "cloud.account.id",
"type": "tag"
},
"op": "=",
"value": "$Account"
},
{
"id": "94012320",
"key": {
"dataType": "string",
"id": "ClusterName--string--tag--false",
"isColumn": false,
"isJSON": false,
"key": "ClusterName",
"type": "tag"
},
"op": "in",
"value": [
"$Cluster"
]
}
],
"op": "AND"
},
"functions": [],
"groupBy": [
{
"dataType": "string",
"id": "ServiceName--string--tag--false",
"isColumn": false,
"isJSON": false,
"key": "ServiceName",
"type": "tag"
},
{
"dataType": "string",
"id": "ClusterName--string--tag--false",
"isColumn": false,
"isJSON": false,
"key": "ClusterName",
"type": "tag"
}
],
"having": [],
"legend": "{{ServiceName}} ({{ClusterName}})",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"spaceAggregation": "max",
"stepInterval": 60,
"timeAggregation": "max"
}
],
"queryFormulas": []
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": ""
}
],
"id": "273e0a76-c780-4b9a-9b03-2649d4227173",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": ""
}
],
"queryType": "builder"
},
"selectedLogFields": [
{
"dataType": "string",
"name": "body",
"type": ""
},
{
"dataType": "string",
"name": "timestamp",
"type": ""
}
],
"selectedTracesFields": [
{
"dataType": "string",
"id": "serviceName--string--tag--true",
"isColumn": true,
"isJSON": false,
"key": "serviceName",
"type": "tag"
},
{
"dataType": "string",
"id": "name--string--tag--true",
"isColumn": true,
"isJSON": false,
"key": "name",
"type": "tag"
},
{
"dataType": "float64",
"id": "durationNano--float64--tag--true",
"isColumn": true,
"isJSON": false,
"key": "durationNano",
"type": "tag"
},
{
"dataType": "string",
"id": "httpMethod--string--tag--true",
"isColumn": true,
"isJSON": false,
"key": "httpMethod",
"type": "tag"
},
{
"dataType": "string",
"id": "responseStatusCode--string--tag--true",
"isColumn": true,
"isJSON": false,
"key": "responseStatusCode",
"type": "tag"
}
],
"softMax": 0,
"softMin": 0,
"stackedBarChart": false,
"thresholds": [],
"timePreferance": "GLOBAL_TIME",
"title": "Maximum CPU Utilization",
"yAxisUnit": "none"
},
{
"bucketCount": 30,
"bucketWidth": 0,
"columnUnits": {},
"description": "",
"fillSpans": false,
"id": "6fac67b0-50ec-4b43-ac4b-320a303d0369",
"isLogScale": false,
"isStacked": false,
"mergeAllActiveQueries": false,
"nullZeroValues": "zero",
"opacity": "1",
"panelTypes": "graph",
"query": {
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "float64",
"id": "aws_ECS_CPUUtilization_min--float64--Gauge--true",
"isColumn": true,
"isJSON": false,
"key": "aws_ECS_CPUUtilization_min",
"type": "Gauge"
},
"aggregateOperator": "min",
"dataSource": "metrics",
"disabled": false,
"expression": "A",
"filters": {
"items": [
{
"id": "758ba906",
"key": {
"dataType": "string",
"id": "cloud.region--string--tag--false",
"isColumn": false,
"isJSON": false,
"key": "cloud.region",
"type": "tag"
},
"op": "=",
"value": "$Region"
},
{
"id": "4ffe6bf7",
"key": {
"dataType": "string",
"id": "cloud.account.id--string--tag--false",
"isColumn": false,
"isJSON": false,
"key": "cloud.account.id",
"type": "tag"
},
"op": "=",
"value": "$Account"
},
{
"id": "53d98059",
"key": {
"dataType": "string",
"id": "ClusterName--string--tag--false",
"isColumn": false,
"isJSON": false,
"key": "ClusterName",
"type": "tag"
},
"op": "in",
"value": [
"$Cluster"
]
}
],
"op": "AND"
},
"functions": [],
"groupBy": [
{
"dataType": "string",
"id": "ServiceName--string--tag--false",
"isColumn": false,
"isJSON": false,
"key": "ServiceName",
"type": "tag"
},
{
"dataType": "string",
"id": "ClusterName--string--tag--false",
"isColumn": false,
"isJSON": false,
"key": "ClusterName",
"type": "tag"
}
],
"having": [],
"legend": "{{ServiceName}} ({{ClusterName}})",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"spaceAggregation": "min",
"stepInterval": 60,
"timeAggregation": "min"
}
],
"queryFormulas": []
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": ""
}
],
"id": "c89482b3-5a98-4e2c-be0d-ef036d7dac05",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": ""
}
],
"queryType": "builder"
},
"selectedLogFields": [
{
"dataType": "string",
"name": "body",
"type": ""
},
{
"dataType": "string",
"name": "timestamp",
"type": ""
}
],
"selectedTracesFields": [
{
"dataType": "string",
"id": "serviceName--string--tag--true",
"isColumn": true,
"isJSON": false,
"key": "serviceName",
"type": "tag"
},
{
"dataType": "string",
"id": "name--string--tag--true",
"isColumn": true,
"isJSON": false,
"key": "name",
"type": "tag"
},
{
"dataType": "float64",
"id": "durationNano--float64--tag--true",
"isColumn": true,
"isJSON": false,
"key": "durationNano",
"type": "tag"
},
{
"dataType": "string",
"id": "httpMethod--string--tag--true",
"isColumn": true,
"isJSON": false,
"key": "httpMethod",
"type": "tag"
},
{
"dataType": "string",
"id": "responseStatusCode--string--tag--true",
"isColumn": true,
"isJSON": false,
"key": "responseStatusCode",
"type": "tag"
}
],
"softMax": 0,
"softMin": 0,
"stackedBarChart": false,
"thresholds": [],
"timePreferance": "GLOBAL_TIME",
"title": "Minimum CPU Utilization",
"yAxisUnit": "none"
}
]
}

View File

@@ -1,18 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="80px" height="80px" viewBox="0 0 80 80" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 64 (93537) - https://sketch.com -->
<title>Icon-Architecture/64/Arch_Amazon-Elastic-Container-Service_64</title>
<desc>Created with Sketch.</desc>
<defs>
<linearGradient x1="0%" y1="100%" x2="100%" y2="0%" id="linearGradient-1">
<stop stop-color="#C8511B" offset="0%"></stop>
<stop stop-color="#FF9900" offset="100%"></stop>
</linearGradient>
</defs>
<g id="Icon-Architecture/64/Arch_Amazon-Elastic-Container-Service_64" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Icon-Architecture-BG/64/Containers" fill="url(#linearGradient-1)">
<rect id="Rectangle" x="0" y="0" width="80" height="80"></rect>
</g>
<path d="M64,48.2340095 L56,43.4330117 L56,32.0000169 C56,31.6440171 55.812,31.3150172 55.504,31.1360173 L44,24.4260204 L44,14.7520248 L64,26.5710194 L64,48.2340095 Z M65.509,25.13902 L43.509,12.139026 C43.199,11.9560261 42.818,11.9540261 42.504,12.131026 C42.193,12.3090259 42,12.6410257 42,13.0000256 L42,25.0000201 C42,25.3550199 42.189,25.6840198 42.496,25.8640197 L54,32.5740166 L54,44.0000114 C54,44.3510113 54.185,44.6770111 54.486,44.857011 L64.486,50.8570083 C64.644,50.9520082 64.822,51 65,51 C65.17,51 65.34,50.9570082 65.493,50.8700083 C65.807,50.6930084 66,50.3600085 66,50 L66,26.0000196 C66,25.6460198 65.814,25.31902 65.509,25.13902 L65.509,25.13902 Z M40.445,66.863001 L17,54.3990067 L17,26.5710194 L37,14.7520248 L37,24.4510204 L26.463,31.1560173 C26.175,31.3400172 26,31.6580171 26,32.0000169 L26,49.0000091 C26,49.373009 26.208,49.7150088 26.538,49.8870087 L39.991,56.8870055 C40.28,57.0370055 40.624,57.0380055 40.912,56.8880055 L53.964,50.1440086 L61.996,54.9640064 L40.445,66.863001 Z M64.515,54.1420068 L54.515,48.1420095 C54.217,47.9640096 53.849,47.9520096 53.541,48.1120095 L40.455,54.8730065 L28,48.3930094 L28,32.5490167 L38.537,25.8440197 C38.825,25.6600198 39,25.3420199 39,25.0000201 L39,13.0000256 C39,12.6410257 38.808,12.3090259 38.496,12.131026 C38.184,11.9540261 37.802,11.9560261 37.491,12.139026 L15.491,25.13902 C15.187,25.31902 15,25.6460198 15,26.0000196 L15,55 C15,55.3690062 15.204,55.7090061 15.53,55.883006 L39.984,68.8830001 C40.131,68.961 40.292,69 40.453,69 C40.62,69 40.786,68.958 40.937,68.8750001 L64.484,55.875006 C64.797,55.7020061 64.993,55.3750062 65.0001416,55.0180064 C65.006,54.6600066 64.821,54.3260067 64.515,54.1420068 L64.515,54.1420068 Z" id="Amazon-Elastic-Container-Service_Icon_64_Squid" fill="#FFFFFF"></path>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.7 KiB

Some files were not shown because too many files have changed in this diff Show More