Compare commits

..

19 Commits

Author SHA1 Message Date
Piyush Singariya
65252943a8 chore: bridging field evolution with promoted 2026-05-26 16:09:06 +05:30
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
Naman Verma
bb471848cc chore: feature flag for dashboard v2 (#11339)
* chore: feature flag for dashboard v2

* fix: fix alignment

* chore: add flag to v1 api as well
2026-05-25 14:08:04 +00:00
SagarRajput-7
bd55e70882 feat(boot-settings): runtime enable/disable control for PostHog and Appcues (#11416)
* feat(boot-settings): move SDK config from build-time env vars to runtime boot data injection

* feat(boot-settings): scope runtime injection to posthog/appcues enabled flags only

* feat(boot-settings): refactor code

* feat(boot-settings): refactor code

* feat(boot-settings): use generated WebSettings types for BE↔FE contract
2026-05-25 13:49:55 +00:00
Jatinderjit Singh
6cf22e98dd feat(planned-downtime): scope maintenance windows to label expressions (#11186)
* add maintenanceMuteStage to move planned maintenance to alertmanager

Rules previously skipped rule.Eval() entirely during maintenance windows.
This change moves suppression to MaintenanceMuter, injected as a Stage
in the alertmanager notification pipeline. Now rules always evaluate and
everys suppression is handled by alertmanager.

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

* refactor: wrap routing pipeline once instead of per-route injection

Replace the per-route-entry loop with a single MultiStage wrap so
maintenance suppression runs once per dispatch group before routing.

* refactor: move maintenance mute stage into custom pipelineBuilder

Copy notify.PipelineBuilder locally so we can inject mms between the
silence stage and the receiver stage (GossipSettle → Inhibit →
TimeActive → TimeMute → Silence → mms → Receiver), matching the
correct suppression order the team requires.

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

* chore: add license header to pipeline_builder.go

Copied code originates from Apache-2.0 licensed Prometheus Alertmanager;
add dual copyright + SPDX identifier following the repo's convention.

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

* chore: replace SPDX tag with full Apache 2.0 license boilerplate

The full license text is unambiguously compliant with Apache 2.0 Section 4(a),
which requires giving recipients "a copy of this License".

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

* refactor: pass MaintenanceMuter directly to pipelineBuilder

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

* refactor: remove dead orgID param from task constructors

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

* rename buildReceiverStage -> createReceiverStage

* refactor: replace maintenanceMuteStage with notify.NewMuteStage

MaintenanceMuter already satisfies types.Muter, and pipelineBuilder has
its own pb.metrics, so the hand-rolled maintenanceMuteStage wrapper is
redundant. Use notify.NewMuteStage(pb.muter, pb.metrics) directly.

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

* refactor: hoist MuteStage construction out of the receiver loop

MuteStage holds no per-receiver state, so one instance shared across
all receivers is sufficient — matching how is/ss are handled upstream.

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

* refactor: always initialize maintenanceStore; remove nil guards

Tests now use a real sqlrulestore-backed MaintenanceMuter instead of
passing nil. With nil no longer a valid input, remove the nil guards
in server.go and pipeline_builder.go.

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

* refactor: move MaintenanceMuter to Server and pass it to pipelineBuilder.New

- Remove muter from pipelineBuilder struct and newPipelineBuilder();
  pass it as a parameter to New() instead, consistent with inhibitor/silencer
- Store muter on Server so GetAlerts can call Mutes() alongside the
  inhibitor and silencer, ensuring maintenance-suppressed alerts show
  the correct muted status in API responses

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

* remove redundant MemMarker wrapper

* feat: surface maintenance-suppressed alerts via mutedBy in GetAlerts

Alerts suppressed by an active maintenance window were being correctly
muted in the notification pipeline but appeared as state=active in the
v2 GetAlerts response, since MaintenanceMuter.Mutes had no marker
side-effect (unlike inhibitor/silencer).

Add MaintenanceMuter.MutedBy returning the matching window IDs, and
plumb a mutedByFunc callback through NewGettableAlertsFromAlertProvider
into AlertToOpenAPIAlert. The upstream v2 API forces state=suppressed
when mutedBy is non-empty, so the frontend's existing state-based
rendering picks it up without further changes.

Use the dedicated mutedBy field rather than SilencedBy to avoid
violating the "complete set of silence IDs" contract that anything
querying silences by ID would rely on.

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

* code cleanup

* refactor: move maintenance (planned downtime) to alertmanager packages

Types move from pkg/types/ruletypes/ to pkg/types/alertmanagertypes/:
- maintenance.go, recurrence.go, schedule.go (+ tests)

Store impl moves from pkg/ruler/rulestore/sqlrulestore/ to
pkg/alertmanager/alertmanagerstore/sqlalertmanagerstore/.

Maintenance windows mute alerts, so they belong with alertmanager
rather than the rule types.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test: add unit tests for MaintenanceMuter

Covers Mutes/MutedBy semantics (empty label, rule match, empty-RuleIDs
matches-all, future windows, multi-window) and the result cache
(single-fetch within TTL, stale-cache fallback on store error,
re-fetch after expiry, concurrency safety).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Update schema changes

* Re-add marker

* fix NewMaintenanceStore in tests

* Go lint fixes

* test: use mockery-generated mock for MaintenanceStore in muter tests

Replace hand-written fakeMaintenanceStore with a mockery-generated
MockMaintenanceStore, consistent with the alertmanagertest pattern.
Also adds MaintenanceStore to .mockery.yml so the mock stays in sync.

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

* chore: regenerate mocks via make gen-mocks

Picks up new MockHandler for the Handler interface in pkg/alertmanager
and regenerates MockMaintenanceStore with canonical mockery formatting.

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

* cleanup test

* test: add e2e muting tests for maintenance window behaviour

* Add label expression support to planned downtime

Alert instances can now be scoped by label expression
(e.g. env == "prod"), scoping suppression below the rule level.
A window with no rule IDs and a label expression silences any
alert whose labels match, regardless of which rule fired it;
when rule IDs are also present, the expression is evaluated only
within the matched rules.

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

* Remove redundant || undefined from labelExpression assignment

* Move label expression evaluation into ShouldSkip

ShouldSkip now owns all three suppression checks in sequence:
rule ID match → schedule active → label expression.
IsActive passes nil labels so the expression check is skipped
(no instance labels available for UI status).

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

* remove redundant LabelSet->map conversion

* implement Down migration to drop label_expression column

* fix lset type and update openapi spec

* fix(tests): resolve envprovider env isolation, factory name length, and ShouldSkip signature

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

* remove unused function `evaluateLabelExpression`

* chore: rename label expression to scope

* test(maintenance): add tests for scope label expression filtering

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

* test(e2e): verify scope-based maintenance muting in alertmanager flow

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

* Use `AND` instead of `&&`

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>

* fix(maintenance): consolidate label-set-to-env conversion to avoid expr panic

Move ConvertLabelSetToEnv to alertmanagertypes so both the maintenance
scope evaluator and the route-policy evaluator share one implementation.
Dotted label keys (e.g. kubernetes.node) are expanded into nested maps,
preventing the expr-lang panic that occurs when one key is a prefix of
another.

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

* chore(planned-downtime): use clickable learn more link in scope tooltip

* chore(sqlmigration): renumber add_scope_to_planned_maintenance to 086

078 collided with add_sa_managed_role_txn; bumped to the next free number
and reordered registration after add_source_to_dashboard (085).

* refactor(maintenance): extract scope expression eval and surface errors

- Move ConvertLabelSetToEnv and EvalScopeExpression into expression.go
  with companion tests in expression_test.go.
- EvalScopeExpression now returns (bool, error) instead of swallowing
  compile/run failures and non-bool outputs; ShouldSkip logs the error
  via slog.Default() and falls back to not suppressing (safety-first).
- Update test fixtures to the SQL-style operator form (`=`, AND, OR)
  matching the placeholder and reviewer suggestions.

* chore: use `=` instead of `==` in expressions

* fix(maintenance): satisfy forbidigo/sloglint in scope eval

- Replace fmt.Errorf with pkg/errors Wrapf/Newf using a new
  ErrCodeInvalidScopeExpression code.
- Use slog ErrorContext (with context.Background()) instead of Error to
  satisfy sloglint.

* perf(maintenance): fold prefix-conflict detection into ConvertLabelSetToEnv

ConvertLabelSetToEnv now returns (env, conflict). The rulebased provider
drops its O(n^2) pre-scan and logs based on the flag, restoring the
previous O(n*d) cost while keeping the shared helper.

* chore: add docs URL for invalid scope

* refactor: don't log in types package

* remove down migration

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2026-05-25 13:40:06 +00:00
Ashwin Bhatkal
39957d322f fix(planned-downtime): remove unused timezone dep from useMemo hooks (#11448)
The startTimeText and endTimeText useMemo hooks did not reference
timezone in their callback bodies, so including it in the dependency
arrays caused unnecessary recomputations whenever the timezone form
field changed.
2026-05-25 13:24:03 +00:00
Vikrant Gupta
d1f143f675 feat(web): add support for generating web settings types (#11445)
* feat(web): add support for generating settings type

* feat(web): add support for generating settings type

* feat(web): add support for generating settings type

* refactor: rename generate settings to generate config web-settings

- Rename cmd/settings.go to cmd/genconfig.go
- Restructure command as `generate config web-settings`
- Move schema output to docs/config/web-settings.json
- Update frontend script to generate:config:web-settings
- Update CI checks to match new command names
- Strip Web prefix from generated JSON Schema definitions
2026-05-25 12:41:24 +00:00
Abhi kumar
1355b13504 chore: added changes to migrate slider component from antd to signozhq/ui (#11411)
* chore: added changes to migrate slider component from antd to signozhq/ui

* chore: package update

* chore(jest): allow copy-text-to-clipboard through transformIgnorePatterns
2026-05-25 12:02:48 +00:00
Vishal Sharma
22d6d5248f feat(ai-assistant): collapse thinking + tool-call steps into one row (#11361)
* feat(ai-assistant): collapse thinking + tool-call steps into one row

Long sequences of thinking and tool-call rows in the chat were noisy and
pushed the actual answer below the fold. ActivityGroup folds any run of
consecutive thinking + tool events behind a single "Worked through N steps"
summary that expands on click. While streaming, the trailing group reads
"Working… · Xs · N steps" with a live elapsed-time tick that re-stamps on
approval/clarification resume.

ThinkingStep now reads "Thinking…" while live and "Thought for a few
seconds" once done. Liveness is derived purely from render position
(trailing item in a trailing live group); persisted history blocks default
to not-live so they render the same wording without depending on
server-stored timing.

* refactor(ai-assistant): tighten ActivityGroup after review

- Bare-render lone activity items: a single thinking or tool step no
  longer renders as "Worked through 1 step" — the underlying chevron is
  enough disclosure.
- Memoize the group partition in MessageBubble and StreamingMessage so
  store updates that don't touch the message's blocks/events don't churn
  the underlying step children.
- Bump the elapsed-timer tick from 500ms to 1000ms (display is
  integer-second precision) and suppress the elapsed token until ≥ 1s.
- Add aria-expanded + aria-controls on the disclosure button and rename
  the SCSS keyframe to activityGroupPulse to avoid a global collision.
- Document the same-instance invariant ActivityGroup's timer relies on
  in groupStreamingEvents.

* refactor(ai-assistant): apply PR review feedback on ActivityGroup

- Reuse formatTime() from utils/timeUtils for the elapsed-time label
  instead of a local formatter.
- Tighten the isLive JSDoc on ActivityGroup and ThinkingStep so the doc
  only captures the non-obvious "why" (timer re-stamp on resume; vague
  copy because the API doesn't persist precise timing).

* refactor(ai-assistant): unify activity rows under ActivityGroup

Drop the bare-render shortcut for single-item activity groups and route
every "what the agent did" row through ActivityGroup. The summary now
adapts to the item count and kind — single-item groups read
"Thinking… / Thought for a few seconds" or the tool's display text
instead of the awkward "Worked through 1 step", and single-item
expansion renders the underlying content body directly (no second
chevron disclosure).

Extracts ThinkingContent / ToolCallContent body sub-components and a
small thinkingLabel / getToolDisplayLabel helper so ActivityGroup can
reuse them without duplicating markup.

* refactor(ai-assistant): apply ActivityGroup review feedback

- Rename common CSS module class names to scoped variants
  (.group → .activityGroup, .header → .activityHeader, etc.) so
  matches against module classes carry intent and don't collide
  with future styles in adjacent files.
- Swap the disclosure <button> for a <div> with onClick — drops the
  signoz Button component option since its action-button defaults
  (focus ring, base padding, hover background) visually regressed
  the quiet full-width row. Matches the existing ThinkingStep /
  ToolCallStep disclosure pattern.
- Drop the useId + aria-controls plumbing; with the disclosure
  collapsed inline beneath the header, the id wasn't carrying
  semantic weight beyond what aria-expanded already provides.
- Thread stable id fields through ActivityItem and the RenderGroup
  types so list keys come from typed data rather than the loop
  index. Persisted tool blocks key off the server-assigned
  toolCallId; streaming items and thinking blocks key off their
  position in the append-only source array.
2026-05-25 11:35:45 +00:00
Abhi kumar
fdbdbf27a8 chore: added changes to migerate alert chart component to new charts (#11308)
* chore: added changes to migerate alert chart component to new charts

* chore: minor changes

* chore: minor changes

* chore: pr review changes

* chore: minor refactor
2026-05-25 11:18:53 +00:00
Nikhil Soni
f47f1ad92b Remove unused field from waterfall response (part 1 of memory opt) (#11337)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* chore: remove unused field from waterfall v3

* chore: update openapi specs

* chore: remove debug statements
2026-05-25 09:36:05 +00:00
Vikrant Gupta
3ffb5bd43b feat(web): add support web settings (#11444)
* feat(web): add support web settings in index.html

* feat(web): remove settings from global config

* feat(web): fix openapi schemas

* feat(web): fix formatting issues

* feat(web): fix formatting issues

* feat(web): remove frontend script changes

* feat(web): remove the redundant test

* feat(web): update defaults
2026-05-25 09:24:12 +00:00
Nityananda Gohain
67324edb7e feat: opamp integration signozspanmapper (#11335)
* feat: opamp integration signozspanmapper

* fix: update go.mod

* fix: minor changes

* fix: keep action as a part of source

* fix: update go.mod

* fix: address comments

* fix: revert changes
2026-05-25 08:30:27 +00:00
Nikhil Soni
9ba57d323d refactor: merge tracedetail typse with spantypes (#11417)
* refactor: merge tracedetail typse with spantypes

* chore: update openapi specs
2026-05-25 06:52:34 +00:00
Tushar Vats
09f4ba33c9 fix: handle body json for default view (#11443) 2026-05-25 06:51:07 +00:00
288 changed files with 25001 additions and 68701 deletions

View File

@@ -123,3 +123,20 @@ jobs:
run: |
go run cmd/enterprise/*.go generate authz
git diff --compact-summary --exit-code || (echo; echo "Unexpected difference in authz permissions. Run go run cmd/enterprise/*.go generate authz locally and commit."; exit 1)
web-settings:
if: |
github.event_name == 'merge_group' ||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
runs-on: ubuntu-latest
steps:
- name: self-checkout
uses: actions/checkout@v4
- name: go-install
uses: actions/setup-go@v5
with:
go-version: "1.24"
- name: generate-web-settings
run: |
go run cmd/enterprise/*.go generate config web-settings
git diff --compact-summary --exit-code || (echo; echo "Unexpected difference in web settings schema. Run go run cmd/enterprise/*.go generate config web-settings locally and commit."; exit 1)

View File

@@ -90,3 +90,26 @@ jobs:
run: |
cd frontend && pnpm generate:api
git diff --compact-summary --exit-code || (echo; echo "Unexpected difference in generated api clients. Run pnpm generate:api in frontend/ locally and commit."; exit 1)
web-settings:
if: |
github.event_name == 'merge_group' ||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
runs-on: ubuntu-latest
steps:
- name: self-checkout
uses: actions/checkout@v4
- name: node-install
uses: actions/setup-node@v5
with:
node-version: "22"
- name: install-pnpm
uses: pnpm/action-setup@v6
with:
version: 10
- name: install-frontend
run: cd frontend && pnpm install
- name: generate-web-settings
run: |
cd frontend && pnpm generate:config:web-settings
git diff --compact-summary --exit-code || (echo; echo "Unexpected difference in generated web settings types. Run pnpm generate:config:web-settings in frontend/ locally and commit."; exit 1)

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

61
cmd/genconfig.go Normal file
View File

@@ -0,0 +1,61 @@
package cmd
import (
"encoding/json"
"os"
"reflect"
"strings"
"github.com/SigNoz/signoz/pkg/web"
"github.com/spf13/cobra"
"github.com/swaggest/jsonschema-go"
)
const webSettingsSchemaPath = "docs/config/web-settings.json"
func registerGenerateConfig(parentCmd *cobra.Command) {
configCmd := &cobra.Command{
Use: "config",
Short: "Generate JSON Schema for config",
}
configCmd.AddCommand(&cobra.Command{
Use: "web-settings",
Short: "Generate JSON Schema for web settings",
RunE: func(currCmd *cobra.Command, args []string) error {
return generateWebSettings()
},
})
parentCmd.AddCommand(configCmd)
}
func generateWebSettings() error {
falseVal := false
noAdditional := jsonschema.SchemaOrBool{TypeBoolean: &falseVal}
reflector := jsonschema.Reflector{}
reflector.DefaultOptions = append(reflector.DefaultOptions,
jsonschema.InterceptSchema(func(params jsonschema.InterceptSchemaParams) (bool, error) {
if params.Value.Kind() == reflect.Struct {
params.Schema.AdditionalProperties = &noAdditional
}
return false, nil
}),
jsonschema.InterceptDefName(func(t reflect.Type, defaultDefName string) string {
return strings.TrimPrefix(defaultDefName, "Web")
}),
)
schema, err := reflector.Reflect(web.Settings{})
if err != nil {
return err
}
data, err := json.MarshalIndent(schema, "", " ")
if err != nil {
return err
}
return os.WriteFile(webSettingsSchemaPath, append(data, '\n'), 0o600)
}

View File

@@ -17,6 +17,7 @@ func RegisterGenerate(parentCmd *cobra.Command, logger *slog.Logger) {
registerGenerateOpenAPI(generateCmd)
registerGenerateAuthz(generateCmd)
registerGenerateConfig(generateCmd)
parentCmd.AddCommand(generateCmd)
}

View File

@@ -60,6 +60,14 @@ web:
index: index.html
# The directory containing the static build files.
directory: /etc/signoz/web
# Settings exposed to the web.
settings:
posthog:
# Whether to enable PostHog in web.
enabled: true
appcues:
# Whether to enable Appcues in web.
enabled: true
##################### Cache #####################
cache:
@@ -174,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

@@ -129,6 +129,8 @@ components:
type: string
schedule:
$ref: '#/components/schemas/AlertmanagertypesSchedule'
scope:
type: string
status:
$ref: '#/components/schemas/AlertmanagertypesMaintenanceStatus'
updatedAt:
@@ -272,6 +274,8 @@ components:
type: string
schedule:
$ref: '#/components/schemas/AlertmanagertypesSchedule'
scope:
type: string
required:
- name
- schedule
@@ -5641,6 +5645,19 @@ components:
type: object
Sigv4SigV4Config:
type: object
SpantypesEvent:
properties:
attributeMap:
additionalProperties: {}
type: object
isError:
type: boolean
name:
type: string
timeUnixNano:
minimum: 0
type: integer
type: object
SpantypesFieldContext:
enum:
- attribute
@@ -5655,6 +5672,44 @@ components:
required:
- items
type: object
SpantypesGettableWaterfallTrace:
properties:
aggregations:
items:
$ref: '#/components/schemas/SpantypesSpanAggregationResult'
nullable: true
type: array
endTimestampMillis:
minimum: 0
type: integer
hasMissingSpans:
type: boolean
hasMore:
type: boolean
rootServiceEntryPoint:
type: string
rootServiceName:
type: string
spans:
items:
$ref: '#/components/schemas/SpantypesWaterfallSpan'
nullable: true
type: array
startTimestampMillis:
minimum: 0
type: integer
totalErrorSpansCount:
minimum: 0
type: integer
totalSpansCount:
minimum: 0
type: integer
uncollapsedSpans:
items:
type: string
nullable: true
type: array
type: object
SpantypesPostableSpanMapper:
properties:
config:
@@ -5682,6 +5737,50 @@ components:
- name
- condition
type: object
SpantypesPostableWaterfall:
properties:
aggregations:
items:
$ref: '#/components/schemas/SpantypesSpanAggregation'
nullable: true
type: array
limit:
minimum: 0
type: integer
selectedSpanId:
type: string
uncollapsedSpans:
items:
type: string
nullable: true
type: array
type: object
SpantypesSpanAggregation:
properties:
aggregation:
$ref: '#/components/schemas/SpantypesSpanAggregationType'
field:
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
type: object
SpantypesSpanAggregationResult:
properties:
aggregation:
$ref: '#/components/schemas/SpantypesSpanAggregationType'
field:
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
value:
additionalProperties:
minimum: 0
type: integer
nullable: true
type: object
type: object
SpantypesSpanAggregationType:
enum:
- span_count
- execution_time_percentage
- duration
type: string
SpantypesSpanMapper:
properties:
config:
@@ -5812,6 +5911,78 @@ components:
nullable: true
type: string
type: object
SpantypesWaterfallSpan:
properties:
attributes:
additionalProperties: {}
nullable: true
type: object
db_name:
type: string
db_operation:
type: string
duration_nano:
minimum: 0
type: integer
events:
items:
$ref: '#/components/schemas/SpantypesEvent'
nullable: true
type: array
external_http_method:
type: string
external_http_url:
type: string
flags:
minimum: 0
type: integer
has_children:
type: boolean
has_error:
type: boolean
http_host:
type: string
http_method:
type: string
http_url:
type: string
is_remote:
type: string
kind_string:
type: string
level:
minimum: 0
type: integer
name:
type: string
parent_span_id:
type: string
resource:
additionalProperties:
type: string
nullable: true
type: object
response_status_code:
type: string
span_id:
type: string
status_code:
type: integer
status_code_string:
type: string
status_message:
type: string
sub_tree_node_count:
minimum: 0
type: integer
time_unix:
minimum: 0
type: integer
trace_id:
type: string
trace_state:
type: string
type: object
TelemetrytypesFieldContext:
enum:
- metric
@@ -5904,179 +6075,6 @@ components:
TimeDuration:
format: int64
type: integer
TracedetailtypesEvent:
properties:
attributeMap:
additionalProperties: {}
type: object
isError:
type: boolean
name:
type: string
timeUnixNano:
minimum: 0
type: integer
type: object
TracedetailtypesGettableWaterfallTrace:
properties:
aggregations:
items:
$ref: '#/components/schemas/TracedetailtypesSpanAggregationResult'
nullable: true
type: array
endTimestampMillis:
minimum: 0
type: integer
hasMissingSpans:
type: boolean
hasMore:
type: boolean
rootServiceEntryPoint:
type: string
rootServiceName:
type: string
serviceNameToTotalDurationMap:
additionalProperties:
minimum: 0
type: integer
nullable: true
type: object
spans:
items:
$ref: '#/components/schemas/TracedetailtypesWaterfallSpan'
nullable: true
type: array
startTimestampMillis:
minimum: 0
type: integer
totalErrorSpansCount:
minimum: 0
type: integer
totalSpansCount:
minimum: 0
type: integer
uncollapsedSpans:
items:
type: string
nullable: true
type: array
type: object
TracedetailtypesPostableWaterfall:
properties:
aggregations:
items:
$ref: '#/components/schemas/TracedetailtypesSpanAggregation'
nullable: true
type: array
limit:
minimum: 0
type: integer
selectedSpanId:
type: string
uncollapsedSpans:
items:
type: string
nullable: true
type: array
type: object
TracedetailtypesSpanAggregation:
properties:
aggregation:
$ref: '#/components/schemas/TracedetailtypesSpanAggregationType'
field:
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
type: object
TracedetailtypesSpanAggregationResult:
properties:
aggregation:
$ref: '#/components/schemas/TracedetailtypesSpanAggregationType'
field:
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
value:
additionalProperties:
minimum: 0
type: integer
nullable: true
type: object
type: object
TracedetailtypesSpanAggregationType:
enum:
- span_count
- execution_time_percentage
- duration
type: string
TracedetailtypesWaterfallSpan:
properties:
attributes:
additionalProperties: {}
nullable: true
type: object
db_name:
type: string
db_operation:
type: string
duration_nano:
minimum: 0
type: integer
events:
items:
$ref: '#/components/schemas/TracedetailtypesEvent'
nullable: true
type: array
external_http_method:
type: string
external_http_url:
type: string
flags:
minimum: 0
type: integer
has_children:
type: boolean
has_error:
type: boolean
http_host:
type: string
http_method:
type: string
http_url:
type: string
is_remote:
type: string
kind_string:
type: string
level:
minimum: 0
type: integer
name:
type: string
parent_span_id:
type: string
resource:
additionalProperties:
type: string
nullable: true
type: object
response_status_code:
type: string
span_id:
type: string
status_code:
type: integer
status_code_string:
type: string
status_message:
type: string
sub_tree_node_count:
minimum: 0
type: integer
time_unix:
minimum: 0
type: integer
trace_id:
type: string
trace_state:
type: string
type: object
TypesAlertStatus:
properties:
inhibitedBy:
@@ -18896,7 +18894,7 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/TracedetailtypesPostableWaterfall'
$ref: '#/components/schemas/SpantypesPostableWaterfall'
responses:
"200":
content:
@@ -18904,7 +18902,7 @@ paths:
schema:
properties:
data:
$ref: '#/components/schemas/TracedetailtypesGettableWaterfallTrace'
$ref: '#/components/schemas/SpantypesGettableWaterfallTrace'
status:
type: string
required:

View File

@@ -0,0 +1,42 @@
{
"required": [
"posthog",
"appcues"
],
"additionalProperties": false,
"definitions": {
"Appcues": {
"required": [
"enabled"
],
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
}
},
"type": "object"
},
"Posthog": {
"required": [
"enabled"
],
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
}
},
"type": "object"
}
},
"properties": {
"appcues": {
"$ref": "#/definitions/Appcues"
},
"posthog": {
"$ref": "#/definitions/Posthog"
}
},
"type": "object"
}

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

@@ -89,6 +89,15 @@ func (ah *APIHandler) getFeatureFlags(w http.ResponseWriter, r *http.Request) {
Route: "",
})
useDashboardV2 := ah.Signoz.Flagger.BooleanOrEmpty(ctx, flagger.FeatureUseDashboardV2, evalCtx)
featureSet = append(featureSet, &licensetypes.Feature{
Name: valuer.NewString(flagger.FeatureUseDashboardV2.String()),
Active: useDashboardV2,
Usage: 0,
UsageLimit: -1,
Route: "",
})
if constants.IsDotMetricsEnabled {
for idx, feature := range featureSet {
if feature.Name == licensetypes.DotMetricsEnabled {

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

@@ -94,6 +94,19 @@
}
})();
</script>
<script type="application/json" id="signoz-boot-settings">
[[.Settings]]
</script>
<script>
try {
var _el = document.getElementById('signoz-boot-settings');
window.signozBootData = {
settings: _el ? JSON.parse(_el.textContent) : null,
};
} catch (e) {
window.signozBootData = { settings: null };
}
</script>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
@@ -135,7 +148,10 @@
</script>
<script>
var APPCUES_APP_ID = '<%- APPCUES_APP_ID %>';
if (APPCUES_APP_ID) {
var appcuesSettings =
((window.signozBootData || {}).settings || {}).appcues || {};
var appcuesEnabled = appcuesSettings.enabled !== false;
if (APPCUES_APP_ID && appcuesEnabled) {
(function (d, t) {
var a = d.createElement(t);
a.async = 1;

View File

@@ -47,10 +47,10 @@ const config: Config.InitialOptions = {
transformIgnorePatterns: [
// @chenglou/pretext is ESM-only; @signozhq/ui pulls it in via text-ellipsis.
// Pattern 1: allow .pnpm virtual store through (handled by pattern 2), plus root-level ESM packages.
'node_modules/(?!(\\.pnpm|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou/pretext|@signozhq/design-tokens|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid)/)',
'node_modules/(?!(\\.pnpm|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou/pretext|@signozhq/design-tokens|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard)/)',
// Pattern 2: pnpm virtual store — ignore everything except ESM-only packages.
// pnpm encodes scoped packages as @scope+name@version, so match on scope prefix.
'node_modules/\\.pnpm/(?!(lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid)[^/]*/node_modules)',
'node_modules/\\.pnpm/(?!(lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard)[^/]*/node_modules)',
],
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
testPathIgnorePatterns: ['/node_modules/', '/public/'],

View File

@@ -24,7 +24,8 @@
"commitlint": "commitlint --edit $1",
"test": "jest",
"test:changedsince": "jest --changedSince=main --coverage --silent",
"generate:api": "orval --config ./orval.config.ts && sh scripts/post-types-generation.sh"
"generate:api": "orval --config ./orval.config.ts && sh scripts/post-types-generation.sh",
"generate:config:web-settings": "json2ts ../docs/config/web-settings.json -o src/types/generated/webSettings.ts --style.useTabs --style.tabWidth=1 --style.singleQuote --bannerComment '/* AUTO GENERATED FILE - DO NOT EDIT - GENERATED FROM docs/config/web-settings.json */'"
},
"engines": {
"node": ">=22.0.0",
@@ -49,7 +50,7 @@
"@signozhq/design-tokens": "2.1.4",
"@signozhq/icons": "0.4.0",
"@signozhq/resizable": "0.0.2",
"@signozhq/ui": "0.0.21",
"@signozhq/ui": "0.0.22",
"@tanstack/react-table": "8.21.3",
"@tanstack/react-virtual": "3.13.22",
"@uiw/codemirror-theme-copilot": "4.23.11",
@@ -160,8 +161,8 @@
"@testing-library/user-event": "14.4.3",
"@types/color": "^3.0.3",
"@types/crypto-js": "4.2.2",
"@types/event-source-polyfill": "^1.0.0",
"@types/d3-hierarchy": "1.1.11",
"@types/event-source-polyfill": "^1.0.0",
"@types/history": "4.7.11",
"@types/jest": "30.0.0",
"@types/lodash-es": "^4.17.4",
@@ -187,6 +188,7 @@
"is-ci": "^3.0.1",
"jest-environment-jsdom": "29.7.0",
"jest-styled-components": "^7.2.0",
"json-schema-to-typescript": "^15.0.4",
"lint-staged": "^17.0.4",
"msw": "1.3.2",
"orval": "8.9.1",
@@ -241,4 +243,4 @@
"tmp": "0.2.4",
"vite": "npm:rolldown-vite@7.3.1"
}
}
}

View File

@@ -77,8 +77,8 @@ importers:
specifier: 0.0.2
version: 0.0.2(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@signozhq/ui':
specifier: 0.0.21
version: 0.0.21(@emotion/is-prop-valid@1.2.0)(@signozhq/icons@0.4.0)(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.3(react@18.2.0))(react@18.2.0)
specifier: 0.0.22
version: 0.0.22(@emotion/is-prop-valid@1.2.0)(@signozhq/icons@0.4.0)(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.3(react@18.2.0))(react@18.2.0)
'@tanstack/react-table':
specifier: 8.21.3
version: 8.21.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
@@ -449,6 +449,9 @@ importers:
jest-styled-components:
specifier: ^7.2.0
version: 7.2.0(styled-components@5.3.11(react-dom@18.2.0(react@18.2.0))(react-is@19.2.6)(react@18.2.0))
json-schema-to-typescript:
specifier: ^15.0.4
version: 15.0.4
lint-staged:
specifier: ^17.0.4
version: 17.0.4
@@ -457,7 +460,7 @@ importers:
version: 1.3.2(typescript@5.9.3)
orval:
specifier: 8.9.1
version: 8.9.1(typescript@5.9.3)
version: 8.9.1(prettier@3.8.3)(typescript@5.9.3)
oxfmt:
specifier: 0.47.0
version: 0.47.0
@@ -545,6 +548,10 @@ packages:
peerDependencies:
react: '>=16.9.0'
'@apidevtools/json-schema-ref-parser@11.9.3':
resolution: {integrity: sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ==}
engines: {node: '>= 16'}
'@babel/code-frame@7.29.0':
resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
engines: {node: '>=6.9.0'}
@@ -1991,6 +1998,9 @@ packages:
'@jridgewell/trace-mapping@0.3.9':
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
'@jsdevtools/ono@7.1.3':
resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==}
'@keyv/bigmap@1.3.1':
resolution: {integrity: sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==}
engines: {node: '>= 18'}
@@ -3269,8 +3279,8 @@ packages:
peerDependencies:
react: ^18.2.0
'@signozhq/ui@0.0.21':
resolution: {integrity: sha512-uLM3Vqwxlk2USXbwtb3qRLpjZR9b9QSHFQq/jtcfYNMDmIE/sNjSj0nRkEhX4RqqRgsLRt2PVA33aeWxDOLO3g==}
'@signozhq/ui@0.0.22':
resolution: {integrity: sha512-CJDyA4H+uXG/U2/d7/nRMNY6WIW0YWc843mfzUQALjm+xOhbO4T+qt67THjV4s1wTMs1cZLkmScbMddf+hXLIQ==}
peerDependencies:
'@signozhq/icons': 0.3.0
react: ^18.2.0
@@ -6066,6 +6076,11 @@ packages:
json-parse-even-better-errors@2.3.1:
resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==}
json-schema-to-typescript@15.0.4:
resolution: {integrity: sha512-Su9oK8DR4xCmDsLlyvadkXzX6+GGXJpbhwoLtOGArAG61dvbW4YQmSEno2y66ahpIdmLMg6YUf/QHLgiwvkrHQ==}
engines: {node: '>=16.0.0'}
hasBin: true
json-schema-traverse@0.4.1:
resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
@@ -7104,6 +7119,11 @@ packages:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
prettier@3.8.3:
resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==}
engines: {node: '>=14'}
hasBin: true
pretty-format@27.5.1:
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
@@ -9044,6 +9064,12 @@ snapshots:
resize-observer-polyfill: 1.5.1
throttle-debounce: 5.0.0
'@apidevtools/json-schema-ref-parser@11.9.3':
dependencies:
'@jsdevtools/ono': 7.1.3
'@types/json-schema': 7.0.15
js-yaml: 4.1.1
'@babel/code-frame@7.29.0':
dependencies:
'@babel/helper-validator-identifier': 7.28.5
@@ -10798,6 +10824,8 @@ snapshots:
'@jridgewell/sourcemap-codec': 1.5.5
optional: true
'@jsdevtools/ono@7.1.3': {}
'@keyv/bigmap@1.3.1(keyv@5.6.0)':
dependencies:
hashery: 1.5.1
@@ -12013,7 +12041,7 @@ snapshots:
- react-dom
- tailwindcss
'@signozhq/ui@0.0.21(@emotion/is-prop-valid@1.2.0)(@signozhq/icons@0.4.0)(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.3(react@18.2.0))(react@18.2.0)':
'@signozhq/ui@0.0.22(@emotion/is-prop-valid@1.2.0)(@signozhq/icons@0.4.0)(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.3(react@18.2.0))(react@18.2.0)':
dependencies:
'@chenglou/pretext': 0.0.5
'@radix-ui/react-checkbox': 1.3.3(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
@@ -15374,6 +15402,18 @@ snapshots:
json-parse-even-better-errors@2.3.1: {}
json-schema-to-typescript@15.0.4:
dependencies:
'@apidevtools/json-schema-ref-parser': 11.9.3
'@types/json-schema': 7.0.15
'@types/lodash': 4.17.24
is-glob: 4.0.3
js-yaml: 4.1.1
lodash: 4.18.1
minimist: 1.2.8
prettier: 3.8.3
tinyglobby: 0.2.15
json-schema-traverse@0.4.1: {}
json-schema-traverse@1.0.0: {}
@@ -16290,7 +16330,7 @@ snapshots:
strip-ansi: 6.0.1
wcwidth: 1.0.1
orval@8.9.1(typescript@5.9.3):
orval@8.9.1(prettier@3.8.3)(typescript@5.9.3):
dependencies:
'@commander-js/extra-typings': 14.0.0(commander@14.0.2)
'@orval/angular': 8.9.1(typescript@5.9.3)
@@ -16321,6 +16361,8 @@ snapshots:
typedoc: 0.28.19(typescript@5.9.3)
typedoc-plugin-coverage: 4.0.2(typedoc@0.28.19(typescript@5.9.3))
typedoc-plugin-markdown: 4.11.0(typedoc@0.28.19(typescript@5.9.3))
optionalDependencies:
prettier: 3.8.3
transitivePeerDependencies:
- '@faker-js/faker'
- supports-color
@@ -16581,6 +16623,8 @@ snapshots:
prelude-ls@1.2.1: {}
prettier@3.8.3: {}
pretty-format@27.5.1:
dependencies:
ansi-regex: 5.0.1

View File

@@ -35,6 +35,7 @@ import { PreferenceContextProvider } from 'providers/preferences/context/Prefere
import { QueryBuilderProvider } from 'providers/QueryBuilder';
import { LicenseStatus } from 'types/api/licensesV3/getActive';
import { extractDomain } from 'utils/app';
import { bootSettings } from 'utils/bootData';
import { Home } from './pageComponents';
import PrivateRoute from './Private';
@@ -332,7 +333,7 @@ function App(): JSX.Element {
useEffect(() => {
if (isCloudUser || isEnterpriseSelfHostedUser) {
if (process.env.POSTHOG_KEY) {
if (bootSettings.posthog.enabled && process.env.POSTHOG_KEY) {
posthog.init(process.env.POSTHOG_KEY, {
api_host: 'https://us.i.posthog.com',
person_profiles: 'identified_only', // or 'always' to create profiles for anonymous users as well

View File

@@ -225,6 +225,10 @@ export interface AlertmanagertypesPlannedMaintenanceDTO {
*/
name: string;
schedule: AlertmanagertypesScheduleDTO;
/**
* @type string
*/
scope?: string;
status: AlertmanagertypesMaintenanceStatusDTO;
/**
* @type string
@@ -1714,6 +1718,10 @@ export interface AlertmanagertypesPostablePlannedMaintenanceDTO {
*/
name: string;
schedule: AlertmanagertypesScheduleDTO;
/**
* @type string
*/
scope?: string;
}
export interface AlertmanagertypesPostableRoutePolicyDTO {
@@ -6655,6 +6663,28 @@ export interface ServiceaccounttypesUpdatableFactorAPIKeyDTO {
name: string;
}
export type SpantypesEventDTOAttributeMap = { [key: string]: unknown };
export interface SpantypesEventDTO {
/**
* @type object
*/
attributeMap?: SpantypesEventDTOAttributeMap;
/**
* @type boolean
*/
isError?: boolean;
/**
* @type string
*/
name?: string;
/**
* @type integer
* @minimum 0
*/
timeUnixNano?: number;
}
export enum SpantypesFieldContextDTO {
attribute = 'attribute',
resource = 'resource',
@@ -6721,6 +6751,219 @@ export interface SpantypesGettableSpanMapperGroupsDTO {
items: SpantypesSpanMapperGroupDTO[];
}
export enum SpantypesSpanAggregationTypeDTO {
span_count = 'span_count',
execution_time_percentage = 'execution_time_percentage',
duration = 'duration',
}
export type SpantypesSpanAggregationResultDTOValueAnyOf = {
[key: string]: number;
};
/**
* @nullable
*/
export type SpantypesSpanAggregationResultDTOValue =
SpantypesSpanAggregationResultDTOValueAnyOf | null;
export interface SpantypesSpanAggregationResultDTO {
aggregation?: SpantypesSpanAggregationTypeDTO;
field?: TelemetrytypesTelemetryFieldKeyDTO;
/**
* @type object,null
*/
value?: SpantypesSpanAggregationResultDTOValue;
}
export type SpantypesWaterfallSpanDTOAttributesAnyOf = {
[key: string]: unknown;
};
/**
* @nullable
*/
export type SpantypesWaterfallSpanDTOAttributes =
SpantypesWaterfallSpanDTOAttributesAnyOf | null;
export type SpantypesWaterfallSpanDTOResourceAnyOf = { [key: string]: string };
/**
* @nullable
*/
export type SpantypesWaterfallSpanDTOResource =
SpantypesWaterfallSpanDTOResourceAnyOf | null;
export interface SpantypesWaterfallSpanDTO {
/**
* @type object,null
*/
attributes?: SpantypesWaterfallSpanDTOAttributes;
/**
* @type string
*/
db_name?: string;
/**
* @type string
*/
db_operation?: string;
/**
* @type integer
* @minimum 0
*/
duration_nano?: number;
/**
* @type array,null
*/
events?: SpantypesEventDTO[] | null;
/**
* @type string
*/
external_http_method?: string;
/**
* @type string
*/
external_http_url?: string;
/**
* @type integer
* @minimum 0
*/
flags?: number;
/**
* @type boolean
*/
has_children?: boolean;
/**
* @type boolean
*/
has_error?: boolean;
/**
* @type string
*/
http_host?: string;
/**
* @type string
*/
http_method?: string;
/**
* @type string
*/
http_url?: string;
/**
* @type string
*/
is_remote?: string;
/**
* @type string
*/
kind_string?: string;
/**
* @type integer
* @minimum 0
*/
level?: number;
/**
* @type string
*/
name?: string;
/**
* @type string
*/
parent_span_id?: string;
/**
* @type object,null
*/
resource?: SpantypesWaterfallSpanDTOResource;
/**
* @type string
*/
response_status_code?: string;
/**
* @type string
*/
span_id?: string;
/**
* @type integer
*/
status_code?: number;
/**
* @type string
*/
status_code_string?: string;
/**
* @type string
*/
status_message?: string;
/**
* @type integer
* @minimum 0
*/
sub_tree_node_count?: number;
/**
* @type integer
* @minimum 0
*/
time_unix?: number;
/**
* @type string
*/
trace_id?: string;
/**
* @type string
*/
trace_state?: string;
}
export interface SpantypesGettableWaterfallTraceDTO {
/**
* @type array,null
*/
aggregations?: SpantypesSpanAggregationResultDTO[] | null;
/**
* @type integer
* @minimum 0
*/
endTimestampMillis?: number;
/**
* @type boolean
*/
hasMissingSpans?: boolean;
/**
* @type boolean
*/
hasMore?: boolean;
/**
* @type string
*/
rootServiceEntryPoint?: string;
/**
* @type string
*/
rootServiceName?: string;
/**
* @type array,null
*/
spans?: SpantypesWaterfallSpanDTO[] | null;
/**
* @type integer
* @minimum 0
*/
startTimestampMillis?: number;
/**
* @type integer
* @minimum 0
*/
totalErrorSpansCount?: number;
/**
* @type integer
* @minimum 0
*/
totalSpansCount?: number;
/**
* @type array,null
*/
uncollapsedSpans?: string[] | null;
}
export enum SpantypesSpanMapperOperationDTO {
move = 'move',
copy = 'copy',
@@ -6770,6 +7013,31 @@ export interface SpantypesPostableSpanMapperGroupDTO {
name: string;
}
export interface SpantypesSpanAggregationDTO {
aggregation?: SpantypesSpanAggregationTypeDTO;
field?: TelemetrytypesTelemetryFieldKeyDTO;
}
export interface SpantypesPostableWaterfallDTO {
/**
* @type array,null
*/
aggregations?: SpantypesSpanAggregationDTO[] | null;
/**
* @type integer
* @minimum 0
*/
limit?: number;
/**
* @type string
*/
selectedSpanId?: string;
/**
* @type array,null
*/
uncollapsedSpans?: string[] | null;
}
export interface SpantypesSpanMapperDTO {
config: SpantypesSpanMapperConfigDTO;
/**
@@ -6878,281 +7146,6 @@ export interface TelemetrytypesGettableFieldValuesDTO {
values: TelemetrytypesTelemetryFieldValuesDTO;
}
export type TracedetailtypesEventDTOAttributeMap = { [key: string]: unknown };
export interface TracedetailtypesEventDTO {
/**
* @type object
*/
attributeMap?: TracedetailtypesEventDTOAttributeMap;
/**
* @type boolean
*/
isError?: boolean;
/**
* @type string
*/
name?: string;
/**
* @type integer
* @minimum 0
*/
timeUnixNano?: number;
}
export type TracedetailtypesGettableWaterfallTraceDTOServiceNameToTotalDurationMapAnyOf =
{ [key: string]: number };
/**
* @nullable
*/
export type TracedetailtypesGettableWaterfallTraceDTOServiceNameToTotalDurationMap =
TracedetailtypesGettableWaterfallTraceDTOServiceNameToTotalDurationMapAnyOf | null;
export enum TracedetailtypesSpanAggregationTypeDTO {
span_count = 'span_count',
execution_time_percentage = 'execution_time_percentage',
duration = 'duration',
}
export type TracedetailtypesSpanAggregationResultDTOValueAnyOf = {
[key: string]: number;
};
/**
* @nullable
*/
export type TracedetailtypesSpanAggregationResultDTOValue =
TracedetailtypesSpanAggregationResultDTOValueAnyOf | null;
export interface TracedetailtypesSpanAggregationResultDTO {
aggregation?: TracedetailtypesSpanAggregationTypeDTO;
field?: TelemetrytypesTelemetryFieldKeyDTO;
/**
* @type object,null
*/
value?: TracedetailtypesSpanAggregationResultDTOValue;
}
export type TracedetailtypesWaterfallSpanDTOAttributesAnyOf = {
[key: string]: unknown;
};
/**
* @nullable
*/
export type TracedetailtypesWaterfallSpanDTOAttributes =
TracedetailtypesWaterfallSpanDTOAttributesAnyOf | null;
export type TracedetailtypesWaterfallSpanDTOResourceAnyOf = {
[key: string]: string;
};
/**
* @nullable
*/
export type TracedetailtypesWaterfallSpanDTOResource =
TracedetailtypesWaterfallSpanDTOResourceAnyOf | null;
export interface TracedetailtypesWaterfallSpanDTO {
/**
* @type object,null
*/
attributes?: TracedetailtypesWaterfallSpanDTOAttributes;
/**
* @type string
*/
db_name?: string;
/**
* @type string
*/
db_operation?: string;
/**
* @type integer
* @minimum 0
*/
duration_nano?: number;
/**
* @type array,null
*/
events?: TracedetailtypesEventDTO[] | null;
/**
* @type string
*/
external_http_method?: string;
/**
* @type string
*/
external_http_url?: string;
/**
* @type integer
* @minimum 0
*/
flags?: number;
/**
* @type boolean
*/
has_children?: boolean;
/**
* @type boolean
*/
has_error?: boolean;
/**
* @type string
*/
http_host?: string;
/**
* @type string
*/
http_method?: string;
/**
* @type string
*/
http_url?: string;
/**
* @type string
*/
is_remote?: string;
/**
* @type string
*/
kind_string?: string;
/**
* @type integer
* @minimum 0
*/
level?: number;
/**
* @type string
*/
name?: string;
/**
* @type string
*/
parent_span_id?: string;
/**
* @type object,null
*/
resource?: TracedetailtypesWaterfallSpanDTOResource;
/**
* @type string
*/
response_status_code?: string;
/**
* @type string
*/
span_id?: string;
/**
* @type integer
*/
status_code?: number;
/**
* @type string
*/
status_code_string?: string;
/**
* @type string
*/
status_message?: string;
/**
* @type integer
* @minimum 0
*/
sub_tree_node_count?: number;
/**
* @type integer
* @minimum 0
*/
time_unix?: number;
/**
* @type string
*/
trace_id?: string;
/**
* @type string
*/
trace_state?: string;
}
export interface TracedetailtypesGettableWaterfallTraceDTO {
/**
* @type array,null
*/
aggregations?: TracedetailtypesSpanAggregationResultDTO[] | null;
/**
* @type integer
* @minimum 0
*/
endTimestampMillis?: number;
/**
* @type boolean
*/
hasMissingSpans?: boolean;
/**
* @type boolean
*/
hasMore?: boolean;
/**
* @type string
*/
rootServiceEntryPoint?: string;
/**
* @type string
*/
rootServiceName?: string;
/**
* @type object,null
*/
serviceNameToTotalDurationMap?: TracedetailtypesGettableWaterfallTraceDTOServiceNameToTotalDurationMap;
/**
* @type array,null
*/
spans?: TracedetailtypesWaterfallSpanDTO[] | null;
/**
* @type integer
* @minimum 0
*/
startTimestampMillis?: number;
/**
* @type integer
* @minimum 0
*/
totalErrorSpansCount?: number;
/**
* @type integer
* @minimum 0
*/
totalSpansCount?: number;
/**
* @type array,null
*/
uncollapsedSpans?: string[] | null;
}
export interface TracedetailtypesSpanAggregationDTO {
aggregation?: TracedetailtypesSpanAggregationTypeDTO;
field?: TelemetrytypesTelemetryFieldKeyDTO;
}
export interface TracedetailtypesPostableWaterfallDTO {
/**
* @type array,null
*/
aggregations?: TracedetailtypesSpanAggregationDTO[] | null;
/**
* @type integer
* @minimum 0
*/
limit?: number;
/**
* @type string
*/
selectedSpanId?: string;
/**
* @type array,null
*/
uncollapsedSpans?: string[] | null;
}
export interface TypesChangePasswordRequestDTO {
/**
* @type string
@@ -9232,7 +9225,7 @@ export type GetWaterfallPathParameters = {
traceID: string;
};
export type GetWaterfall200 = {
data: TracedetailtypesGettableWaterfallTraceDTO;
data: SpantypesGettableWaterfallTraceDTO;
/**
* @type string
*/

View File

@@ -15,7 +15,7 @@ import type {
GetWaterfall200,
GetWaterfallPathParameters,
RenderErrorResponseDTO,
TracedetailtypesPostableWaterfallDTO,
SpantypesPostableWaterfallDTO,
} from '../sigNoz.schemas';
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
@@ -27,14 +27,14 @@ import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
*/
export const getWaterfall = (
{ traceID }: GetWaterfallPathParameters,
tracedetailtypesPostableWaterfallDTO?: BodyType<TracedetailtypesPostableWaterfallDTO>,
spantypesPostableWaterfallDTO?: BodyType<SpantypesPostableWaterfallDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetWaterfall200>({
url: `/api/v3/traces/${traceID}/waterfall`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: tracedetailtypesPostableWaterfallDTO,
data: spantypesPostableWaterfallDTO,
signal,
});
};
@@ -48,7 +48,7 @@ export const getGetWaterfallMutationOptions = <
TError,
{
pathParams: GetWaterfallPathParameters;
data?: BodyType<TracedetailtypesPostableWaterfallDTO>;
data?: BodyType<SpantypesPostableWaterfallDTO>;
},
TContext
>;
@@ -57,7 +57,7 @@ export const getGetWaterfallMutationOptions = <
TError,
{
pathParams: GetWaterfallPathParameters;
data?: BodyType<TracedetailtypesPostableWaterfallDTO>;
data?: BodyType<SpantypesPostableWaterfallDTO>;
},
TContext
> => {
@@ -74,7 +74,7 @@ export const getGetWaterfallMutationOptions = <
Awaited<ReturnType<typeof getWaterfall>>,
{
pathParams: GetWaterfallPathParameters;
data?: BodyType<TracedetailtypesPostableWaterfallDTO>;
data?: BodyType<SpantypesPostableWaterfallDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
@@ -89,7 +89,7 @@ export type GetWaterfallMutationResult = NonNullable<
Awaited<ReturnType<typeof getWaterfall>>
>;
export type GetWaterfallMutationBody =
| BodyType<TracedetailtypesPostableWaterfallDTO>
| BodyType<SpantypesPostableWaterfallDTO>
| undefined;
export type GetWaterfallMutationError = ErrorType<RenderErrorResponseDTO>;
@@ -105,7 +105,7 @@ export const useGetWaterfall = <
TError,
{
pathParams: GetWaterfallPathParameters;
data?: BodyType<TracedetailtypesPostableWaterfallDTO>;
data?: BodyType<SpantypesPostableWaterfallDTO>;
},
TContext
>;
@@ -114,7 +114,7 @@ export const useGetWaterfall = <
TError,
{
pathParams: GetWaterfallPathParameters;
data?: BodyType<TracedetailtypesPostableWaterfallDTO>;
data?: BodyType<SpantypesPostableWaterfallDTO>;
},
TContext
> => {

View File

@@ -5,7 +5,10 @@ import cx from 'classnames';
import { VIEW_TYPES } from 'components/LogDetail/constants';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
import { getSanitizedLogBody } from 'container/LogDetailedView/utils';
import {
getBodyDisplayString,
getSanitizedLogBody,
} from 'container/LogDetailedView/utils';
import { FontSize } from 'container/OptionsMenu/types';
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
import { useIsDarkMode } from 'hooks/useDarkMode';
@@ -196,7 +199,7 @@ function ListLogView({
{updatedSelecedFields.some((field) => field.name === 'body') && (
<LogGeneralField
fieldKey="Log"
fieldValue={flattenLogData.body}
fieldValue={getBodyDisplayString(logData.body)}
linesPerRow={linesPerRow}
fontSize={fontSize}
/>

View File

@@ -51,6 +51,10 @@
}
}
}
.duration-input-slider {
padding: 12px 0px;
margin-top: 12px;
}
}
.divider {

View File

@@ -116,28 +116,17 @@ jest.mock('hooks/useNotifications', (): unknown => ({
}));
// mock theme hook
jest.mock('hooks/useDarkMode', (): unknown => {
const useThemeModeMock = (): {
jest.mock('hooks/useDarkMode', (): unknown => ({
useThemeMode: (): {
setAutoSwitch: jest.Mock;
setTheme: jest.Mock;
toggleTheme: jest.Mock;
theme: string;
autoSwitch: boolean;
} => ({
setAutoSwitch: jest.fn(),
setTheme: jest.fn(),
toggleTheme: jest.fn(),
theme: 'dark',
autoSwitch: false,
});
return {
__esModule: true,
default: useThemeModeMock,
useThemeMode: useThemeModeMock,
useIsDarkMode: (): boolean => true,
useSystemTheme: (): 'dark' | 'light' => 'dark',
};
});
}),
}));
// mock updateUserPreference API and react-query mutation
jest.mock('api/v1/user/preferences/name/update', (): jest.Mock => jest.fn());

View File

@@ -10,7 +10,6 @@ import {
} from '@signozhq/ui/command';
import logEvent from 'api/common/logEvent';
import { useThemeMode } from 'hooks/useDarkMode';
import { useThemeSelection } from 'hooks/useDarkMode/useThemeSelection';
import history from 'lib/history';
import { ROLES as UserRole } from 'types/roles';
@@ -37,8 +36,7 @@ export function CmdKPalette({
}): JSX.Element | null {
const { open, setOpen } = useCmdK();
const { theme } = useThemeMode();
const selectTheme = useThemeSelection();
const { setAutoSwitch, setTheme, theme } = useThemeMode();
// toggle palette with ⌘/Ctrl+K
function handleGlobalCmdK(
@@ -68,10 +66,12 @@ export function CmdKPalette({
function handleThemeChange(value: string): void {
logEvent('Account Settings: Theme Changed', { theme: value });
// Close the palette inside the same flushSync batch as the theme change
// so its dismissal is part of the captured "new" frame of the wipe;
// otherwise the dialog would be visible in both snapshots and flicker.
selectTheme(value, () => setOpen(false));
if (value === 'auto') {
setAutoSwitch(true);
} else {
setAutoSwitch(false);
setTheme(value);
}
}
function onClickHandler(key: string): void {

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

@@ -11,4 +11,5 @@ export enum FeatureKeys {
DOT_METRICS_ENABLED = 'dot_metrics_enabled',
USE_JSON_BODY = 'use_json_body',
USE_FINE_GRAINED_AUTHZ = 'use_fine_grained_authz',
USE_DASHBOARD_V2 = 'use_dashboard_v2',
}

View File

@@ -0,0 +1,64 @@
// Collapsed activity summary — one row that hides the underlying
// thinking + tool-call steps. Reuses the same quiet treatment as
// ThinkingStep / ToolCallStep so it sits flush in the assistant bubble.
.activityGroup {
width: 100%;
font-size: 12px;
}
.activityHeader {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
background: transparent;
border: none;
cursor: pointer;
color: var(--l3-foreground);
user-select: none;
transition: color 0.12s ease;
width: 100%;
text-align: left;
&:hover {
color: var(--l1-foreground);
}
}
.sparkleIcon {
flex-shrink: 0;
color: var(--accent-primary);
&.iconPulsing {
animation: activityGroupPulse 1.4s ease-in-out infinite;
}
}
@keyframes activityGroupPulse {
0%,
100% {
opacity: 0.55;
}
50% {
opacity: 1;
}
}
.activitySummary {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 400;
}
.toggleChevron {
flex-shrink: 0;
color: inherit;
}
.activityBody {
display: flex;
flex-direction: column;
padding: 2px 0 4px;
}

View File

@@ -0,0 +1,148 @@
import { useEffect, useRef, useState } from 'react';
import cx from 'classnames';
import { ChevronDown, ChevronRight, Sparkles } from '@signozhq/icons';
import { formatTime } from 'utils/timeUtils';
import { StreamingToolCall } from '../../types';
import ThinkingStep, { ThinkingContent, thinkingLabel } from '../ThinkingStep';
import ToolCallStep, {
getToolDisplayLabel,
ToolCallContent,
} from '../ToolCallStep';
import styles from './ActivityGroup.module.scss';
export type ActivityItem =
| { id: string; kind: 'thinking'; content: string }
| { id: string; kind: 'tool'; toolCall: StreamingToolCall };
interface ActivityGroupProps {
items: ActivityItem[];
/**
* True only for the trailing activity group of an active stream — drives
* the live "Working…" label and the elapsed-time tick (which re-stamps on
* approval/clarification resume so wait time isn't counted).
*/
isLive?: boolean;
}
/**
* Single-item groups get a step-specific summary so the user doesn't see a
* pointless "Worked through 1 step". Multi-item groups roll up into the
* generic "Working… / Worked through N steps" treatment.
*/
function buildSummary(
items: ActivityItem[],
isLive: boolean,
elapsed: number,
): string {
if (items.length === 1) {
const [only] = items;
if (only.kind === 'thinking') {
return thinkingLabel(isLive);
}
return getToolDisplayLabel(only.toolCall);
}
const stepLabel = `${items.length} steps`;
if (!isLive) {
return `Worked through ${stepLabel}`;
}
// Suppress the elapsed token until ≥ 1s — the first tick fires after
// 1s anyway, and showing "0s" or "<1s" briefly adds noise.
return elapsed >= 1000
? `Working… · ${formatTime(elapsed / 1000)} · ${stepLabel}`
: `Working… · ${stepLabel}`;
}
/**
* Single collapsed summary row that hides a run of thinking + tool-call steps.
* Expands to show each underlying step inline. Used for every activity row
* (including single-item ones) so all "what the agent did" rows share a
* consistent ✨-led visual contract.
*/
export default function ActivityGroup({
items,
isLive = false,
}: ActivityGroupProps): JSX.Element {
const [expanded, setExpanded] = useState(false);
// Captures the moment this live phase started. Re-stamped on every
// false→true transition so a stream that pauses on
// approval/clarification and resumes doesn't roll the user's wait time
// into the elapsed counter.
const startedAtRef = useRef<number>(Date.now());
const wasLiveRef = useRef<boolean>(isLive);
const [elapsed, setElapsed] = useState(0);
useEffect(() => {
if (isLive && !wasLiveRef.current) {
startedAtRef.current = Date.now();
setElapsed(0);
}
wasLiveRef.current = isLive;
if (!isLive) {
return undefined;
}
// Tick once per second — the display is integer-second precision, so
// faster ticks would just re-render the bubble for no visible change.
const id = window.setInterval(() => {
setElapsed(Date.now() - startedAtRef.current);
}, 1000);
return (): void => window.clearInterval(id);
}, [isLive]);
const summary = buildSummary(items, isLive, elapsed);
const isSingle = items.length === 1;
const toggle = (): void => setExpanded((v) => !v);
return (
<div className={styles.activityGroup}>
<div className={styles.activityHeader} onClick={toggle}>
<Sparkles
size={12}
className={cx(styles.sparkleIcon, { [styles.iconPulsing]: isLive })}
/>
<span className={styles.activitySummary}>{summary}</span>
{expanded ? (
<ChevronDown size={12} className={styles.toggleChevron} />
) : (
<ChevronRight size={12} className={styles.toggleChevron} />
)}
</div>
{expanded && (
<div className={styles.activityBody}>
{isSingle ? (
// Single-item: the outer chevron already provides disclosure,
// so render the underlying content directly instead of wrapping
// it in a second collapsible step row.
items[0].kind === 'thinking' ? (
<ThinkingContent content={items[0].content} />
) : (
<ToolCallContent toolCall={items[0].toolCall} />
)
) : (
items.map((item, i) => {
// A thinking step is live only while it's the trailing item
// in a trailing live group — once any later event (text or
// tool) arrives, the pass is done.
const isLastItem = i === items.length - 1;
return item.kind === 'thinking' ? (
<ThinkingStep
key={item.id}
content={item.content}
isLive={isLive && isLastItem}
/>
) : (
<ToolCallStep key={item.id} toolCall={item.toolCall} />
);
})
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,2 @@
export { default } from './ActivityGroup';
export type { ActivityItem } from './ActivityGroup';

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useMemo } from 'react';
import cx from 'classnames';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
@@ -9,11 +9,10 @@ import '../blocks';
import { useVariant } from '../../VariantContext';
import { Message, MessageBlock } from '../../types';
import ActionsSection from '../ActionsSection';
import ActivityGroup, { ActivityItem } from '../ActivityGroup';
import { RichCodeBlock } from '../blocks';
import { MessageContext } from '../MessageContext';
import MessageFeedback from '../MessageFeedback';
import ThinkingStep from '../ThinkingStep';
import ToolCallStep from '../ToolCallStep';
import UserMessageActions from '../UserMessageActions';
import styles from './MessageBubble.module.scss';
@@ -40,38 +39,61 @@ function SmartPre({ children }: { children?: React.ReactNode }): JSX.Element {
const MD_PLUGINS = [remarkGfm];
const MD_COMPONENTS = { code: RichCodeBlock, pre: SmartPre };
/** Renders a single MessageBlock by type. */
function renderBlock(block: MessageBlock, index: number): JSX.Element {
switch (block.type) {
case 'thinking':
return <ThinkingStep key={index} content={block.content} />;
case 'tool_call':
// Blocks in a persisted message are always complete — done is always true.
return (
<ToolCallStep
key={index}
toolCall={{
toolName: block.toolName,
input: block.toolInput,
result: block.result,
done: true,
displayText: block.displayText,
}}
/>
);
case 'text':
default:
return (
<ReactMarkdown
key={index}
className={styles.markdown}
remarkPlugins={MD_PLUGINS}
components={MD_COMPONENTS}
>
{block.content}
</ReactMarkdown>
);
type RenderGroup =
| { kind: 'text'; id: string; content: string }
| { kind: 'activity'; id: string; items: ActivityItem[] };
/**
* Partition message blocks into render groups so consecutive thinking and
* tool_call blocks collapse into a single ActivityGroup row. Text blocks
* stand alone, mirroring the streaming view.
*/
function groupBlocks(blocks: MessageBlock[]): RenderGroup[] {
const groups: RenderGroup[] = [];
blocks.forEach((block, i) => {
if (block.type === 'text') {
groups.push({ kind: 'text', id: `text-${i}`, content: block.content });
return;
}
const item: ActivityItem =
block.type === 'thinking'
? { id: `t-${i}`, kind: 'thinking', content: block.content }
: {
id: `c-${block.toolCallId}`,
kind: 'tool',
// Persisted blocks are always complete.
toolCall: {
toolName: block.toolName,
input: block.toolInput,
result: block.result,
done: true,
displayText: block.displayText,
},
};
const last = groups[groups.length - 1];
if (last?.kind === 'activity') {
last.items.push(item);
} else {
groups.push({ kind: 'activity', id: `a-${i}`, items: [item] });
}
});
return groups;
}
function renderGroup(group: RenderGroup): JSX.Element {
if (group.kind === 'text') {
return (
<ReactMarkdown
key={group.id}
className={styles.markdown}
remarkPlugins={MD_PLUGINS}
components={MD_COMPONENTS}
>
{group.content}
</ReactMarkdown>
);
}
return <ActivityGroup key={group.id} items={group.items} />;
}
interface MessageBubbleProps {
@@ -90,6 +112,14 @@ export default function MessageBubble({
const isUser = message.role === 'user';
const hasBlocks = !isUser && message.blocks && message.blocks.length > 0;
// Recompute groups only when the blocks array identity changes — store
// updates that don't touch this message's blocks should not re-render the
// underlying ThinkingStep/ToolCallStep children.
const groups = useMemo(
() => (hasBlocks ? groupBlocks(message.blocks!) : []),
[hasBlocks, message.blocks],
);
const messageClass = cx(
styles.message,
isUser ? styles.user : styles.assistant,
@@ -128,8 +158,7 @@ export default function MessageBubble({
<p className={styles.text}>{message.content}</p>
) : hasBlocks ? (
<MessageContext.Provider value={{ messageId: message.id }}>
{/* eslint-disable-next-line react/no-array-index-key */}
{message.blocks!.map((block, i) => renderBlock(block, i))}
{groups.map((g) => renderGroup(g))}
</MessageContext.Provider>
) : (
<MessageContext.Provider value={{ messageId: message.id }}>

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useMemo } from 'react';
import cx from 'classnames';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
@@ -10,11 +10,10 @@ import type {
import { useVariant } from '../../VariantContext';
import { StreamingEventItem } from '../../types';
import ActivityGroup, { ActivityItem } from '../ActivityGroup';
import ApprovalCard from '../ApprovalCard';
import { RichCodeBlock } from '../blocks';
import ClarificationForm from '../ClarificationForm';
import ThinkingStep from '../ThinkingStep';
import ToolCallStep from '../ToolCallStep';
import messageStyles from '../MessageBubble/MessageBubble.module.scss';
import styles from './StreamingMessage.module.scss';
@@ -33,6 +32,59 @@ function SmartPre({ children }: { children?: React.ReactNode }): JSX.Element {
const MD_PLUGINS = [remarkGfm];
const MD_COMPONENTS = { code: RichCodeBlock, pre: SmartPre };
type RenderGroup =
| { kind: 'text'; id: string; content: string }
| {
kind: 'activity';
id: string;
items: ActivityItem[];
isTrailing: boolean;
};
/**
* Partition the streaming event timeline into render groups: runs of
* consecutive thinking/tool events fold into a single activity group, text
* events stay standalone. The last group is flagged as trailing so the
* caller can drive a "live" indicator on it.
*
* Invariant relied on by the ActivityGroup elapsed-time timer: once a
* group exists at a given array index, later events only extend its
* `items` — they never shrink the array or re-key existing groups. That
* keeps each ActivityGroup React instance stable across re-renders so the
* timer's `wasLive` → `isLive` re-stamp captures the right transition.
* The id fields below piggyback on that invariant: each event's position in
* `events` is stable, so the derived id stays stable across re-renders.
*/
function groupStreamingEvents(events: StreamingEventItem[]): RenderGroup[] {
const groups: RenderGroup[] = [];
events.forEach((event, i) => {
if (event.kind === 'text') {
groups.push({ kind: 'text', id: `text-${i}`, content: event.content });
return;
}
const item: ActivityItem =
event.kind === 'thinking'
? { id: `t-${i}`, kind: 'thinking', content: event.content }
: { id: `c-${i}`, kind: 'tool', toolCall: event.toolCall };
const last = groups[groups.length - 1];
if (last?.kind === 'activity') {
last.items.push(item);
} else {
groups.push({
kind: 'activity',
id: `a-${i}`,
items: [item],
isTrailing: false,
});
}
});
const last = groups[groups.length - 1];
if (last?.kind === 'activity') {
last.isTrailing = true;
}
return groups;
}
/** Human-readable labels for execution status codes shown before any events arrive. */
const STATUS_LABEL: Record<string, string> = {
queued: 'Queued…',
@@ -79,6 +131,11 @@ export default function StreamingMessage({
[messageStyles.compact]: isCompact,
});
// Recompute groups only when the events array identity changes. The
// streaming reducer pushes new entries into the same array reference
// once per tick, so this naturally invalidates as events arrive.
const groups = useMemo(() => groupStreamingEvents(events), [events]);
return (
<div className={messageClass}>
<div className={messageStyles.bubble}>
@@ -88,27 +145,28 @@ export default function StreamingMessage({
)}
{isEmpty && !statusLabel && <TypingDots />}
{/* eslint-disable react/no-array-index-key */}
{/* Events rendered in arrival order: text, thinking, and tool calls interleaved */}
{events.map((event, i) => {
if (event.kind === 'tool') {
return <ToolCallStep key={i} toolCall={event.toolCall} />;
}
if (event.kind === 'thinking') {
return <ThinkingStep key={i} content={event.content} />;
{/* Runs of consecutive thinking + tool events collapse into a
single ActivityGroup; text events render inline between
them. The trailing group is "live" while streaming is
active and not blocked on the user. */}
{groups.map((group) => {
if (group.kind === 'text') {
return (
<ReactMarkdown
key={group.id}
className={messageStyles.markdown}
remarkPlugins={MD_PLUGINS}
components={MD_COMPONENTS}
>
{group.content}
</ReactMarkdown>
);
}
const groupIsLive = group.isTrailing && !isWaitingOnUser;
return (
<ReactMarkdown
key={i}
className={messageStyles.markdown}
remarkPlugins={MD_PLUGINS}
components={MD_COMPONENTS}
>
{event.content}
</ReactMarkdown>
<ActivityGroup key={group.id} items={group.items} isLive={groupIsLive} />
);
})}
{/* eslint-enable react/no-array-index-key */}
{/* While events are still streaming, append the typing dots so the
user has a clear "more is coming" signal. Hidden when the agent

View File

@@ -5,11 +5,31 @@ import styles from './ThinkingStep.module.scss';
interface ThinkingStepProps {
content: string;
/**
* When false, label reads "Thought for a few seconds" — intentionally
* vague because the API doesn't persist precise timing, so showing
* seconds would be inconsistent between fresh and reloaded threads.
*/
isLive?: boolean;
}
/** Body of a thinking step — extracted so ActivityGroup can render it directly. */
export function ThinkingContent({ content }: { content: string }): JSX.Element {
return (
<div className={styles.body}>
<p className={styles.content}>{content}</p>
</div>
);
}
export function thinkingLabel(isLive: boolean): string {
return isLive ? 'Thinking…' : 'Thought for a few seconds';
}
/** Collapsible thinking row — chevron + label, content in the expanded body. */
export default function ThinkingStep({
content,
isLive = false,
}: ThinkingStepProps): JSX.Element {
const [expanded, setExpanded] = useState(false);
@@ -23,14 +43,10 @@ export default function ThinkingStep({
) : (
<ChevronRight size={12} className={styles.chevron} />
)}
<span className={styles.label}>Thinking</span>
<span className={styles.label}>{thinkingLabel(isLive)}</span>
</div>
{expanded && (
<div className={styles.body}>
<p className={styles.content}>{content}</p>
</div>
)}
{expanded && <ThinkingContent content={content} />}
</div>
);
}

View File

@@ -10,24 +10,58 @@ interface ToolCallStepProps {
toolCall: StreamingToolCall;
}
/**
* Server-supplied `displayText` is the human-friendly title the backend
* wants surfaced. Falls back to a derived label
* ("signoz_get_dashboard" → "Get Dashboard") when missing.
*/
export function getToolDisplayLabel(toolCall: StreamingToolCall): string {
const { toolName, displayText } = toolCall;
if (displayText && displayText.trim().length > 0) {
return displayText;
}
return toolName
.replace(/^[a-z]+_/, '') // strip prefix like "signoz_"
.replace(/_/g, ' ')
.replace(/\b\w/g, (c) => c.toUpperCase());
}
/** Body of a tool-call step — extracted so ActivityGroup can render it directly. */
export function ToolCallContent({
toolCall,
}: {
toolCall: StreamingToolCall;
}): JSX.Element {
const { toolName, input, result, done } = toolCall;
return (
<div className={styles.body}>
<div className={styles.section}>
<span className={styles.sectionLabel}>Tool</span>
<span className={styles.toolName}>{toolName}</span>
</div>
<div className={styles.section}>
<span className={styles.sectionLabel}>Input</span>
<pre className={styles.json}>{JSON.stringify(input, null, 2)}</pre>
</div>
{done && result !== undefined && (
<div className={styles.section}>
<span className={styles.sectionLabel}>Output</span>
<pre className={styles.json}>
{typeof result === 'string' ? result : JSON.stringify(result, null, 2)}
</pre>
</div>
)}
</div>
);
}
/** Collapsible tool-call row — chevron + label, in/out detail in the body. */
export default function ToolCallStep({
toolCall,
}: ToolCallStepProps): JSX.Element {
const [expanded, setExpanded] = useState(false);
const { toolName, input, result, done, displayText } = toolCall;
// Prefer the server-supplied `displayText` from `ToolCallEventDTO` —
// it's the human-friendly title the backend wants surfaced. Fall back
// to a derived label ("signoz_get_dashboard" → "Get Dashboard") when
// the field is empty / null / missing.
const label =
displayText && displayText.trim().length > 0
? displayText
: toolName
.replace(/^[a-z]+_/, '') // strip prefix like "signoz_"
.replace(/_/g, ' ')
.replace(/\b\w/g, (c) => c.toUpperCase());
const { done } = toolCall;
const label = getToolDisplayLabel(toolCall);
const toggle = (): void => setExpanded((v) => !v);
@@ -44,26 +78,7 @@ export default function ToolCallStep({
<span className={styles.label}>{label}</span>
</div>
{expanded && (
<div className={styles.body}>
<div className={styles.section}>
<span className={styles.sectionLabel}>Tool</span>
<span className={styles.toolName}>{toolName}</span>
</div>
<div className={styles.section}>
<span className={styles.sectionLabel}>Input</span>
<pre className={styles.json}>{JSON.stringify(input, null, 2)}</pre>
</div>
{done && result !== undefined && (
<div className={styles.section}>
<span className={styles.sectionLabel}>Output</span>
<pre className={styles.json}>
{typeof result === 'string' ? result : JSON.stringify(result, null, 2)}
</pre>
</div>
)}
</div>
)}
{expanded && <ToolCallContent toolCall={toolCall} />}
</div>
);
}

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

@@ -91,7 +91,6 @@ function ChartPreview({
const renderQBChartPreview = (): JSX.Element => (
<ChartPreviewComponent
headline={headline}
name=""
query={stagedQuery}
selectedInterval={globalSelectedInterval}
alertDef={alertDef}
@@ -107,7 +106,6 @@ function ChartPreview({
const renderPromAndChQueryChartPreview = (): JSX.Element => (
<ChartPreviewComponent
headline={headline}
name="Chart Preview"
query={stagedQuery}
alertDef={alertDef}
selectedInterval={globalSelectedInterval}

View File

@@ -17,7 +17,6 @@ import { CreateAlertProvider } from '../../context';
import ChartPreview from '../ChartPreview/ChartPreview';
const REQUESTS_PER_SEC = 'requests/sec';
const CHART_PREVIEW_NAME = 'Chart Preview';
const QUERY_TYPE_TEST_ID = 'query-type';
const GRAPH_TYPE_TEST_ID = 'graph-type';
const CHART_PREVIEW_COMPONENT_TEST_ID = 'chart-preview-component';
@@ -34,7 +33,6 @@ jest.mock(
return (
<div data-testid={CHART_PREVIEW_COMPONENT_TEST_ID}>
<div data-testid="headline">{props.headline}</div>
<div data-testid="name">{props.name}</div>
<div data-testid={QUERY_TYPE_TEST_ID}>{props.query?.queryType}</div>
<div data-testid="selected-interval">
{props.selectedInterval?.startTime}
@@ -175,12 +173,6 @@ describe('ChartPreview', () => {
);
});
it('renders QueryBuilder chart preview with empty name when query type is QUERY_BUILDER', () => {
renderChartPreview();
expect(screen.getByTestId('name')).toHaveTextContent('');
});
it('renders QueryBuilder chart preview with correct props', () => {
renderChartPreview();
@@ -191,7 +183,6 @@ describe('ChartPreview', () => {
expect(screen.getByTestId(GRAPH_TYPE_TEST_ID)).toHaveTextContent(
PANEL_TYPES.TIME_SERIES,
);
expect(screen.getByTestId('name')).toHaveTextContent('');
expect(screen.getByTestId('headline')).toBeInTheDocument();
expect(screen.getByTestId('selected-interval')).toBeInTheDocument();
});
@@ -214,7 +205,6 @@ describe('ChartPreview', () => {
expect(
screen.getByTestId(CHART_PREVIEW_COMPONENT_TEST_ID),
).toBeInTheDocument();
expect(screen.getByTestId('name')).toHaveTextContent(CHART_PREVIEW_NAME);
expect(screen.getByTestId(QUERY_TYPE_TEST_ID)).toHaveTextContent(
EQueryType.PROM,
);
@@ -238,7 +228,6 @@ describe('ChartPreview', () => {
expect(
screen.getByTestId(CHART_PREVIEW_COMPONENT_TEST_ID),
).toBeInTheDocument();
expect(screen.getByTestId('name')).toHaveTextContent(CHART_PREVIEW_NAME);
expect(screen.getByTestId(QUERY_TYPE_TEST_ID)).toHaveTextContent(
EQueryType.CLICKHOUSE,
);

View File

@@ -17,10 +17,11 @@ import { getTimeRange } from 'utils/getTimeRange';
import BarChart from '../../charts/BarChart/BarChart';
import ChartManager from '../../components/ChartManager/ChartManager';
import { usePanelContextMenu } from '../../hooks/usePanelContextMenu';
import { prepareBarPanelConfig, prepareBarPanelData } from './utils';
import { prepareBarPanelConfig } from './utils';
import '../Panel.styles.scss';
import TooltipFooter from '../components/TooltipFooter';
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
function BarPanel(props: PanelWrapperProps): JSX.Element {
const {
@@ -99,7 +100,7 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
if (!queryResponse?.data?.payload) {
return [];
}
return prepareBarPanelData(queryResponse?.data?.payload);
return prepareChartData(queryResponse?.data?.payload);
}, [queryResponse?.data?.payload]);
const layoutChildren = useMemo(() => {

View File

@@ -11,21 +11,10 @@ import { get } from 'lodash-es';
import { Widgets } from 'types/api/dashboard/getAll';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { AlignedData } from 'uplot';
import { PanelMode } from '../types';
import { fillMissingXAxisTimestamps, getXAxisTimestamps } from '../utils';
import { buildBaseConfig } from '../utils/baseConfigBuilder';
export function prepareBarPanelData(
apiResponse: MetricRangePayloadProps,
): AlignedData {
const seriesList = apiResponse?.data?.result || [];
const timestampArr = getXAxisTimestamps(seriesList);
const yAxisValuesArr = fillMissingXAxisTimestamps(timestampArr, seriesList);
return [timestampArr, ...yAxisValuesArr];
}
export function prepareBarPanelConfig({
widget,
isDarkMode,

View File

@@ -17,10 +17,11 @@ import { useTimezone } from 'providers/Timezone';
import uPlot from 'uplot';
import { getTimeRange } from 'utils/getTimeRange';
import { prepareChartData, prepareUPlotConfig } from '../TimeSeriesPanel/utils';
import { prepareUPlotConfig } from '../TimeSeriesPanel/utils';
import '../Panel.styles.scss';
import TooltipFooter from '../components/TooltipFooter';
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
const {

View File

@@ -6,7 +6,8 @@ import {
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { PanelMode } from '../../types';
import { prepareChartData, prepareUPlotConfig } from '../utils';
import { prepareUPlotConfig } from '../utils';
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
jest.mock(
'container/DashboardContainer/visualization/panels/utils/legendVisibilityUtils',

View File

@@ -1,10 +1,6 @@
import { ExecStats } from 'api/v5/v5';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import {
fillMissingXAxisTimestamps,
getXAxisTimestamps,
} from 'container/DashboardContainer/visualization/panels/utils';
import { getLegend } from 'lib/dashboard/getQueryResults';
import getLabelName from 'lib/getLabelName';
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
@@ -15,42 +11,15 @@ import {
LineStyle,
} from 'lib/uPlotV2/config/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { isInvalidPlotValue } from 'lib/uPlotV2/utils/dataUtils';
import { hasSingleVisiblePoint } from 'lib/uPlotV2/utils/dataUtils';
import get from 'lodash-es/get';
import { Widgets } from 'types/api/dashboard/getAll';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { QueryData } from 'types/api/widgets/getQuery';
import { PanelMode } from '../types';
import { buildBaseConfig } from '../utils/baseConfigBuilder';
export const prepareChartData = (
apiResponse: MetricRangePayloadProps,
): uPlot.AlignedData => {
const seriesList = apiResponse?.data?.result || [];
const timestampArr = getXAxisTimestamps(seriesList);
const yAxisValuesArr = fillMissingXAxisTimestamps(timestampArr, seriesList);
return [timestampArr, ...yAxisValuesArr];
};
function hasSingleVisiblePointForSeries(series: QueryData): boolean {
const rawValues = series.values ?? [];
let validPointCount = 0;
for (const [, rawValue] of rawValues) {
if (!isInvalidPlotValue(rawValue)) {
validPointCount += 1;
if (validPointCount > 1) {
return false;
}
}
}
return true;
}
export const prepareUPlotConfig = ({
widget,
isDarkMode,
@@ -107,7 +76,7 @@ export const prepareUPlotConfig = ({
}
apiResponse.data.result.forEach((series) => {
const hasSingleValidPoint = hasSingleVisiblePointForSeries(series);
const hasSingleValidPoint = hasSingleVisiblePoint(series.values);
const baseLabelName = getLabelName(
series.metric,
series.queryName || '', // query

View File

@@ -0,0 +1,118 @@
import { useMemo } from 'react';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import BarChart from 'container/DashboardContainer/visualization/charts/BarChart/BarChart';
import TimeSeries from 'container/DashboardContainer/visualization/charts/TimeSeries/TimeSeries';
import { ThresholdProps } from 'container/NewWidget/RightContainer/Threshold/types';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import uPlot from 'uplot';
import {
AlertChartPanelType,
buildAlertChartConfig,
buildChartId,
} from './utils';
// Panel types that render through the UPlotConfigBuilder pipeline.
// To support a new modern-chart panel type, add an entry here and extend
// `AlertChartPanelType` / `buildAlertChartConfig` to handle its series setup.
const SUPPORTED_CHARTS: Record<
AlertChartPanelType,
typeof TimeSeries | typeof BarChart
> = {
[PANEL_TYPES.TIME_SERIES]: TimeSeries,
[PANEL_TYPES.BAR]: BarChart,
};
const isSupportedPanelType = (
panelType: PANEL_TYPES,
): panelType is AlertChartPanelType => panelType in SUPPORTED_CHARTS;
export interface ChartContentProps {
panelType: PANEL_TYPES;
alertId?: string;
query: Query;
apiResponse?: MetricRangePayloadProps;
data: uPlot.AlignedData;
thresholds: ThresholdProps[];
yAxisUnit: string;
legendPosition: LegendPosition;
isDarkMode: boolean;
timezone: Timezone;
width: number;
height: number;
minTimeScale?: number;
maxTimeScale?: number;
onDragSelect: (start: number, end: number) => void;
}
export default function ChartContent({
panelType,
alertId,
query,
thresholds,
apiResponse,
data,
yAxisUnit,
isDarkMode,
timezone,
minTimeScale,
maxTimeScale,
onDragSelect,
width,
height,
legendPosition,
}: ChartContentProps): JSX.Element | null {
const supported = isSupportedPanelType(panelType);
const config = useMemo(
() =>
buildAlertChartConfig({
id: buildChartId(alertId),
panelType: panelType as AlertChartPanelType,
query,
thresholds,
apiResponse,
yAxisUnit,
isDarkMode,
timezone,
minTimeScale,
maxTimeScale,
onDragSelect,
}),
[
alertId,
panelType,
query,
thresholds,
apiResponse,
yAxisUnit,
isDarkMode,
timezone,
minTimeScale,
maxTimeScale,
onDragSelect,
],
);
if (!supported) {
return null;
}
const Component = SUPPORTED_CHARTS[panelType];
return (
<Component
config={config}
data={data}
width={width}
height={height}
legendConfig={{ position: legendPosition }}
canPinTooltip
yAxisUnit={yAxisUnit}
timezone={timezone}
/>
);
}

View File

@@ -15,8 +15,6 @@ import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import AnomalyAlertEvaluationView from 'container/AnomalyAlertEvaluationView';
import { INITIAL_CRITICAL_THRESHOLD } from 'container/CreateAlertV2/context/constants';
import { Threshold } from 'container/CreateAlertV2/context/types';
import { getLocalStorageGraphVisibilityState } from 'container/GridCardLayout/GridCard/utils';
import GridPanelSwitch from 'container/GridPanelSwitch';
import { populateMultipleResults } from 'container/NewWidget/LeftContainer/WidgetGraph/util';
import { getFormatNameByOptionId } from 'container/NewWidget/RightContainer/alertFomatCategories';
import { timePreferenceType } from 'container/NewWidget/RightContainer/timeItems';
@@ -32,8 +30,7 @@ import useUrlQuery from 'hooks/useUrlQuery';
import GetMinMax from 'lib/getMinMax';
import getTimeString from 'lib/getTimeString';
import history from 'lib/history';
import { getUPlotChartOptions } from 'lib/uPlotLib/getUplotChartOptions';
import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import { isEmpty } from 'lodash-es';
import { useAppContext } from 'providers/App/App';
import { useTimezone } from 'providers/Timezone';
@@ -41,24 +38,27 @@ import { UpdateTimeInterval } from 'store/actions';
import { AppState } from 'store/reducers';
import { Warning } from 'types/api';
import { AlertDef } from 'types/api/alerts/def';
import { LegendPosition } from 'types/api/dashboard/getAll';
import APIError from 'types/api/error';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { GlobalReducer } from 'types/reducer/globalTime';
import uPlot from 'uplot';
import { getGraphType } from 'utils/getGraphType';
import { getSortedSeriesData } from 'utils/getSortedSeriesData';
import { getTimeRange } from 'utils/getTimeRange';
import { AlertDetectionTypes } from '..';
import ChartContent from './ChartContent';
import { ChartContainer } from './styles';
import { getThresholds } from './utils';
import './ChartPreview.styles.scss';
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
// Height reserved for the `.chart-preview-header` strip rendered above the chart.
const CHART_PREVIEW_HEADER_HEIGHT = 48;
const CHART_PREVIEW_CONTAINER_PADDING = 16;
export interface ChartPreviewProps {
name: string;
query: Query | null;
graphType?: PANEL_TYPES;
selectedTime?: timePreferenceType;
@@ -77,7 +77,6 @@ export interface ChartPreviewProps {
// eslint-disable-next-line sonarjs/cognitive-complexity
function ChartPreview({
name,
query,
graphType = PANEL_TYPES.TIME_SERIES,
selectedTime = 'GLOBAL_TIME',
@@ -113,14 +112,6 @@ function ChartPreview({
const [minTimeScale, setMinTimeScale] = useState<number>();
const [maxTimeScale, setMaxTimeScale] = useState<number>();
const [graphVisibility, setGraphVisibility] = useState<boolean[]>([]);
const legendScrollPositionRef = useRef<{
scrollTop: number;
scrollLeft: number;
}>({
scrollTop: 0,
scrollLeft: 0,
});
const { currentQuery } = useQueryBuilder();
const {
@@ -219,18 +210,6 @@ function ChartPreview({
setMaxTimeScale(endTime);
}, [maxTime, minTime, globalSelectedInterval, queryResponse, setQueryStatus]);
// Initialize graph visibility from localStorage
useEffect(() => {
if (queryResponse?.data?.payload?.data?.result) {
const { graphVisibilityStates: localStoredVisibilityState } =
getLocalStorageGraphVisibilityState({
apiResponse: queryResponse.data.payload.data.result,
name: 'alert-chart-preview',
});
setGraphVisibility(localStoredVisibilityState);
}
}, [queryResponse?.data?.payload?.data?.result]);
if (queryResponse.data && graphType === PANEL_TYPES.BAR) {
const sortedSeriesData = getSortedSeriesData(
queryResponse.data?.payload.data.result,
@@ -288,62 +267,17 @@ function ChartPreview({
return LegendPosition.RIGHT;
}, [queryResponse?.data?.payload?.data?.result?.length, showSideLegend]);
const options = useMemo(
() =>
getUPlotChartOptions({
id: 'alert_legend_widget',
yAxisUnit,
apiResponse: queryResponse?.data?.payload,
dimensions: {
height: containerDimensions?.height ? containerDimensions.height - 48 : 0,
width: containerDimensions?.width,
},
minTimeScale,
maxTimeScale,
isDarkMode,
onDragSelect,
thresholds: getThresholds(thresholds, t, optionName, yAxisUnit),
softMax: null,
softMin: null,
panelType: graphType,
tzDate: (timestamp: number) =>
uPlot.tzDate(new Date(timestamp * 1e3), timezone.value),
timezone: timezone.value,
currentQuery,
query: query || currentQuery,
graphsVisibilityStates: graphVisibility,
setGraphsVisibilityStates: setGraphVisibility,
enhancedLegend: true,
legendPosition,
legendScrollPosition: legendScrollPositionRef.current,
setLegendScrollPosition: (position: {
scrollTop: number;
scrollLeft: number;
}) => {
legendScrollPositionRef.current = position;
},
}),
[
yAxisUnit,
queryResponse?.data?.payload,
containerDimensions,
minTimeScale,
maxTimeScale,
isDarkMode,
onDragSelect,
thresholds,
t,
optionName,
graphType,
timezone.value,
currentQuery,
query,
graphVisibility,
legendPosition,
],
const resolvedThresholds = useMemo(
() => getThresholds(thresholds, t, optionName, yAxisUnit),
[thresholds, t, optionName, yAxisUnit],
);
const chartData = getUPlotChartData(queryResponse?.data?.payload);
const chartData = useMemo(() => {
if (!queryResponse?.data?.payload) {
return [];
}
return prepareChartData(queryResponse?.data?.payload);
}, [queryResponse?.data?.payload]);
const hasResultData = !!queryResponse?.data?.payload?.data?.result?.length;
@@ -361,6 +295,14 @@ function ChartPreview({
?.active || false;
const isWarning = !isEmpty(queryResponse.data?.warning);
const chartWidth = containerDimensions?.width
? containerDimensions.width - CHART_PREVIEW_CONTAINER_PADDING
: 0;
const chartHeight = containerDimensions?.height
? containerDimensions.height - CHART_PREVIEW_HEADER_HEIGHT
: 0;
return (
<div className="alert-chart-container" ref={graphRef}>
<ChartContainer>
@@ -384,16 +326,22 @@ function ChartPreview({
)}
{chartDataAvailable && !isAnomalyDetectionAlert && (
<GridPanelSwitch
options={options}
<ChartContent
panelType={graphType}
alertId={alertDef?.id}
query={query || currentQuery}
apiResponse={queryResponse.data?.payload}
data={chartData}
name={name || 'Chart Preview'}
panelData={
queryResponse.data?.payload?.data?.newResult?.data?.result || []
}
query={query || initialQueriesMap.metrics}
thresholds={resolvedThresholds}
yAxisUnit={yAxisUnit}
legendPosition={legendPosition}
isDarkMode={isDarkMode}
timezone={timezone}
width={chartWidth}
height={chartHeight}
minTimeScale={minTimeScale}
maxTimeScale={maxTimeScale}
onDragSelect={onDragSelect}
/>
)}

View File

@@ -1,6 +1,10 @@
import { Color } from '@signozhq/design-tokens';
import { ExecStats } from 'api/v5/v5';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { Threshold } from 'container/CreateAlertV2/context/types';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import { buildBaseConfig } from 'container/DashboardContainer/visualization/panels/utils/baseConfigBuilder';
import { ThresholdProps } from 'container/NewWidget/RightContainer/Threshold/types';
import {
BooleanFormats,
@@ -11,6 +15,20 @@ import {
TimeFormats,
} from 'container/NewWidget/RightContainer/types';
import { TFunction } from 'i18next';
import { getLegend } from 'lib/dashboard/getQueryResults';
import getLabelName from 'lib/getLabelName';
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
import {
DrawStyle,
FillMode,
LineInterpolation,
LineStyle,
} from 'lib/uPlotV2/config/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { hasSingleVisiblePoint } from 'lib/uPlotV2/utils/dataUtils';
import { get } from 'lodash-es';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import {
dataFormatConfig,
@@ -20,6 +38,8 @@ import {
timeUnitsConfig,
} from './config';
const CHART_ID_PREFIX = 'alert_legend_widget';
export function covertIntoDataFormats({
value,
sourceUnit,
@@ -142,3 +162,110 @@ export const getThresholds = (
});
return thresholdsToReturn;
};
export type AlertChartPanelType = PANEL_TYPES.TIME_SERIES | PANEL_TYPES.BAR;
export interface BuildAlertChartConfigParams {
id: string;
panelType: AlertChartPanelType;
query: Query;
thresholds: ThresholdProps[];
apiResponse?: MetricRangePayloadProps;
yAxisUnit?: string;
isDarkMode: boolean;
timezone: Timezone;
minTimeScale?: number;
maxTimeScale?: number;
onDragSelect: (startTime: number, endTime: number) => void;
onClick?: OnClickPluginOpts['onClick'];
}
export const buildAlertChartConfig = ({
id,
panelType,
query,
thresholds,
apiResponse,
yAxisUnit,
isDarkMode,
timezone,
minTimeScale,
maxTimeScale,
onDragSelect,
onClick,
}: BuildAlertChartConfigParams): UPlotConfigBuilder => {
const stepIntervals: ExecStats['stepIntervals'] = get(
apiResponse,
'data.newResult.meta.stepIntervals',
{},
);
const stepIntervalValues = Object.values(stepIntervals);
const minStepInterval = stepIntervalValues.length
? Math.min(...stepIntervalValues)
: undefined;
const builder = buildBaseConfig({
id,
panelType,
panelMode: PanelMode.DASHBOARD_VIEW,
thresholds,
apiResponse,
yAxisUnit,
isDarkMode,
timezone,
minTimeScale,
maxTimeScale,
stepInterval: minStepInterval,
onDragSelect,
onClick,
});
const seriesList = apiResponse?.data?.result;
if (!seriesList?.length) {
return builder;
}
const isBar = panelType === PANEL_TYPES.BAR;
seriesList.forEach((series) => {
const baseLabelName = getLabelName(
series.metric,
series.queryName || '',
series.legend || '',
);
const label = query ? getLegend(series, query, baseLabelName) : baseLabelName;
if (isBar) {
builder.addSeries({
scaleKey: 'y',
drawStyle: DrawStyle.Bar,
label,
colorMapping: {},
isDarkMode,
stepInterval: get(stepIntervals, series.queryName, undefined),
});
return;
}
const hasSingleValidPoint = hasSingleVisiblePoint(series.values);
builder.addSeries({
scaleKey: 'y',
drawStyle: hasSingleValidPoint ? DrawStyle.Points : DrawStyle.Line,
label,
colorMapping: {},
spanGaps: true,
lineStyle: LineStyle.Solid,
lineInterpolation: LineInterpolation.Spline,
showPoints: hasSingleValidPoint,
pointSize: 5,
fillMode: FillMode.None,
isDarkMode,
metric: series.metric,
});
});
return builder;
};
export const buildChartId = (id?: string): string =>
id ? `${CHART_ID_PREFIX}_${id}` : CHART_ID_PREFIX;

View File

@@ -719,7 +719,6 @@ function FormAlertRules({
panelType={panelType || PANEL_TYPES.TIME_SERIES}
/>
}
name=""
query={stagedQuery}
selectedInterval={globalSelectedInterval}
alertDef={alertDef}
@@ -739,7 +738,6 @@ function FormAlertRules({
panelType={panelType || PANEL_TYPES.TIME_SERIES}
/>
}
name="Chart Preview"
query={stagedQuery}
alertDef={alertDef}
selectedInterval={globalSelectedInterval}

View File

@@ -12,8 +12,6 @@ import APIError from 'types/api/error';
import { toast } from '@signozhq/ui/sonner';
const toggleThemeFunction = jest.fn();
const setThemeFunction = jest.fn();
const setAutoSwitchFunction = jest.fn();
const logEventFunction = jest.fn();
const copyToClipboardFn = jest.fn();
const editUserFn = jest.fn();
@@ -58,11 +56,9 @@ jest.mock('hooks/useDarkMode', () => ({
useIsDarkMode: jest.fn(() => true),
useSystemTheme: jest.fn(() => 'dark'),
default: jest.fn(() => ({
theme: 'dark',
setTheme: setThemeFunction,
toggleTheme: toggleThemeFunction,
autoSwitch: false,
setAutoSwitch: setAutoSwitchFunction,
setAutoSwitch: jest.fn(),
})),
}));
@@ -138,8 +134,7 @@ describe('MySettings Flows', () => {
fireEvent.click(lightOption);
await waitFor(() => {
expect(setAutoSwitchFunction).toHaveBeenCalledWith(false);
expect(setThemeFunction).toHaveBeenCalledWith('light');
expect(toggleThemeFunction).toHaveBeenCalled();
expect(logEventFunction).toHaveBeenCalledWith(
'Account Settings: Theme Changed',
{

View File

@@ -8,8 +8,6 @@ import updateUserPreference from 'api/v1/user/preferences/name/update';
import { AxiosError } from 'axios';
import { USER_PREFERENCES } from 'constants/userPreferences';
import useThemeMode, { useIsDarkMode, useSystemTheme } from 'hooks/useDarkMode';
import { THEME_MODE } from 'hooks/useDarkMode/constant';
import { useThemeSelection } from 'hooks/useDarkMode/useThemeSelection';
import { useNotifications } from 'hooks/useNotifications';
import { MonitorCog, Moon, Sun } from '@signozhq/icons';
import { useAppContext } from 'providers/App/App';
@@ -25,10 +23,9 @@ import './MySettings.styles.scss';
function MySettings(): JSX.Element {
const isDarkMode = useIsDarkMode();
const { userPreferences, updateUserPreferenceInContext } = useAppContext();
const { autoSwitch } = useThemeMode();
const { toggleTheme, autoSwitch, setAutoSwitch } = useThemeMode();
const systemTheme = useSystemTheme();
const { notifications } = useNotifications();
const selectTheme = useThemeSelection();
const [sideNavPinned, setSideNavPinned] = useState(false);
@@ -61,7 +58,7 @@ function MySettings(): JSX.Element {
<Moon data-testid="dark-theme-icon" size={12} /> Dark{' '}
</div>
),
value: THEME_MODE.DARK,
value: 'dark',
},
{
label: (
@@ -72,7 +69,7 @@ function MySettings(): JSX.Element {
</Tag>
</div>
),
value: THEME_MODE.LIGHT,
value: 'light',
},
{
label: (
@@ -80,29 +77,46 @@ function MySettings(): JSX.Element {
<MonitorCog size={12} data-testid="auto-theme-icon" /> System{' '}
</div>
),
value: THEME_MODE.SYSTEM,
value: 'auto',
},
];
const [theme, setTheme] = useState(() => {
if (autoSwitch) {
return THEME_MODE.SYSTEM;
return 'auto';
}
return isDarkMode ? THEME_MODE.DARK : THEME_MODE.LIGHT;
return isDarkMode ? 'dark' : 'light';
});
const handleThemeChange = (event: RadioChangeEvent): void => {
const { value } = event.target;
logEvent('Account Settings: Theme Changed', { theme: value });
selectTheme(value);
const handleThemeChange = ({ target: { value } }: RadioChangeEvent): void => {
logEvent('Account Settings: Theme Changed', {
theme: value,
});
setTheme(value);
if (value === 'auto') {
setAutoSwitch(true);
} else {
setAutoSwitch(false);
// Only toggle if the current theme is different from the target
const targetIsDark = value === 'dark';
if (targetIsDark !== isDarkMode) {
toggleTheme();
}
}
};
useEffect(() => {
if (autoSwitch) {
setTheme(THEME_MODE.SYSTEM);
setTheme('auto');
return;
}
setTheme(isDarkMode ? THEME_MODE.DARK : THEME_MODE.LIGHT);
if (isDarkMode) {
setTheme('dark');
} else {
setTheme('light');
}
}, [autoSwitch, isDarkMode]);
const handleSideNavPinnedChange = (checked: boolean): void => {

View File

@@ -295,37 +295,6 @@
.slider-container {
width: calc(100% - 16px);
.ant-slider .ant-slider-mark {
margin-top: 12px;
.ant-slider-mark-text {
color: var(--l3-foreground);
font-variant-numeric: lining-nums tabular-nums stacked-fractions
slashed-zero;
font-feature-settings:
'dlig' on,
'salt' on,
'cpsp' on,
'case' on;
font-family: Inter;
font-size: 11px;
font-style: normal;
font-weight: 400;
line-height: 100%;
}
}
&.logs-slider-container {
.ant-slider .ant-slider-mark {
.ant-slider-mark-text {
&:last-child {
left: calc(100% - 8px) !important;
white-space: nowrap;
}
}
}
}
}
.do-later-container {

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import { Button } from '@signozhq/ui/button';
import { Slider } from 'antd';
import { Slider } from '@signozhq/ui/slider';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import { ArrowRight, LoaderCircle, Minus } from '@signozhq/icons';
@@ -204,23 +204,23 @@ function OptimiseSignozNeeds({
<label className="question-slider" htmlFor="organisationName">
Logs / Day
</label>
<div className="slider-container logs-slider-container">
<div className="slider-container">
<div>
<Slider
min={0}
max={100}
value={sliderValues.logsPerDay}
marks={marks}
onChange={(value: number): void =>
handleSliderChange('logsPerDay', value)
onChange={(value): void =>
handleSliderChange('logsPerDay', value as number)
}
styles={{
track: {
background: '#4E74F8',
range: {
backgroundColor: '#4E74F8',
},
}}
tooltip={{
formatter: (): string => `${logsPerDayValue.toLocaleString()} GB`, // Show whole number
formatter: (): string => `${logsPerDayValue.toLocaleString()} GB`,
}}
/>
</div>
@@ -238,16 +238,16 @@ function OptimiseSignozNeeds({
max={100}
value={sliderValues.hostsPerDay}
marks={hostMarks}
onChange={(value: number): void =>
handleSliderChange('hostsPerDay', value)
onChange={(value): void =>
handleSliderChange('hostsPerDay', value as number)
}
styles={{
track: {
background: '#4E74F8',
range: {
backgroundColor: '#4E74F8',
},
}}
tooltip={{
formatter: (): string => `${hostsPerDayValue.toLocaleString()}`, // Show whole number
formatter: (): string => `${hostsPerDayValue.toLocaleString()}`,
}}
/>
</div>
@@ -265,16 +265,16 @@ function OptimiseSignozNeeds({
max={100}
value={sliderValues.services}
marks={serviceMarks}
onChange={(value: number): void =>
handleSliderChange('services', value)
onChange={(value): void =>
handleSliderChange('services', value as number)
}
styles={{
track: {
background: '#4E74F8',
range: {
backgroundColor: '#4E74F8',
},
}}
tooltip={{
formatter: (): string => `${servicesValue.toLocaleString()}`, // Show whole number
formatter: (): string => `${servicesValue.toLocaleString()}`,
}}
/>
</div>

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

@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Check } from '@signozhq/icons';
import { Check, Info } from '@signozhq/icons';
import {
Button,
DatePicker,
@@ -8,9 +8,11 @@ import {
FormInstance,
Input,
Modal,
Radio,
Select,
SelectProps,
Spin,
Tooltip,
} from 'antd';
import { Typography } from '@signozhq/ui/typography';
import type { DefaultOptionType } from 'antd/es/select';
@@ -70,14 +72,18 @@ 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;
scope?: string;
}
const customFormat = DATE_TIME_FORMATS.ORDINAL_DATETIME;
@@ -127,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();
@@ -140,10 +152,14 @@ 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: {
startTime: values.startTime?.format(),
endTime: values.endTime?.format(),
@@ -262,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: {
@@ -278,11 +295,13 @@ export function PlannedDowntimeForm(
duration: getDurationInfo(schedule?.recurrence?.duration)?.value ?? '',
} as AlertmanagertypesRecurrenceDTO,
timezone: schedule?.timezone as string,
scope: initialValues.scope || '',
};
}, [initialValues, alertOptions]);
useEffect(() => {
setSelectedTags(formattedInitialValues.alertRules);
setAlertRuleScope(formattedInitialValues.alertRuleScope);
form.setFieldsValue({ ...formattedInitialValues });
}, [form, formattedInitialValues, initialValues]);
@@ -311,7 +330,7 @@ export function PlannedDowntimeForm(
default:
return `Scheduled for ${formattedStartDate} starting at ${formattedStartTime}.`;
}
}, [formData, recurrenceType, timezone]);
}, [formData, recurrenceType]);
const endTimeText = useMemo((): string => {
const endTime = formData.endTime;
@@ -322,7 +341,7 @@ export function PlannedDowntimeForm(
const formattedEndTime = endTime.format(TIME_FORMAT);
const formattedEndDate = endTime.format(DATE_FORMAT);
return `Scheduled to end maintenance on ${formattedEndDate} at ${formattedEndTime}.`;
}, [formData, recurrenceType, timezone]);
}, [formData, recurrenceType]);
return (
<Modal
@@ -345,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"
@@ -444,50 +464,107 @@ 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={
<span>
Scope&nbsp;
<Tooltip
mouseLeaveDelay={0.3}
title={
<span>
Scope the planned downtime by alert labels.{' '}
<a
href="https://signoz.io/docs/alerts-management/planned-maintenance/#scoping-with-label-expressions"
target="_blank"
rel="noopener noreferrer"
>
Learn more
</a>
</span>
}
>
<Info size={13} />
</Tooltip>
</span>
}
name="scope"
>
<Input.TextArea
placeholder='e.g. env = "prod" AND region = "us-east-1"'
autoSize={{ minRows: 2, maxRows: 4 }}
/>
</Form.Item>
<Form.Item style={{ marginBottom: 0 }}>
<ModalButtonWrapper>
<Button

View File

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

View File

@@ -8,8 +8,7 @@ import {
} from 'react';
// eslint-disable-next-line no-restricted-imports
import { useDispatch, useSelector } from 'react-redux';
import { Slider } from 'antd';
import type { SliderRangeProps } from 'antd/lib/slider';
import { Slider } from '@signozhq/ui/slider';
import getFilters from 'api/trace/getFilters';
import useDebouncedFn from 'hooks/useDebouncedFunction';
// eslint-disable-next-line no-restricted-imports
@@ -169,16 +168,15 @@ function Duration(): JSX.Element {
debouncedFunction(min, max);
};
const onRangeHandler: SliderRangeProps['onChange'] = ([min, max]) => {
const onRangeHandler = (value: number | number[]): void => {
const [min, max] = value as number[];
updatedUrl(min, max);
};
const TipComponent = useCallback((value: undefined | number) => {
if (value === undefined) {
return <div />;
}
return <div>{`${value?.toString()}ms`}</div>;
}, []);
const TipComponent = useCallback(
(value: number) => <div>{`${value.toString()}ms`}</div>,
[],
);
return (
<div>
@@ -210,7 +208,8 @@ function Duration(): JSX.Element {
max={Number(getMs(String(preLocalMaxDuration.current || 0)))}
range
tooltip={{ formatter: TipComponent }}
onChange={([min, max]): void => {
onChange={(value): void => {
const [min, max] = value as number[];
onRangeSliderHandler([String(min), String(max)]);
}}
onAfterChange={onRangeHandler}

View File

@@ -1,64 +0,0 @@
import { useCallback } from 'react';
import {
canAnimateThemeTransition,
runThemeTransition,
} from 'utils/themeTransition';
import useThemeMode, { useSystemTheme } from './index';
import { THEME_MODE } from './constant';
type SelectTheme = (value: string, onApplied?: () => void) => void;
// Centralises the "apply a theme selection" flow used by MySettings and the
// command palette: figures out whether the visible (dark↔light) theme is
// actually flipping, applies the state change, and — when capable — wraps the
// change in a left→right view-transition wipe.
//
// `value` is one of THEME_MODE.{LIGHT,DARK,SYSTEM}; `onApplied` runs inside the
// same flushSync batch as the theme change (useful for, e.g., closing the
// command palette so its dismissal is part of the captured "new" snapshot).
export function useThemeSelection(): SelectTheme {
const { theme, setTheme, setAutoSwitch } = useThemeMode();
const systemTheme = useSystemTheme();
return useCallback<SelectTheme>(
(value, onApplied) => {
const currentIsDark = theme === THEME_MODE.DARK;
// When switching to SYSTEM, the visible theme flips iff the OS preference
// differs from what we're currently rendering.
const resolvedTargetIsDark =
value === THEME_MODE.SYSTEM
? systemTheme === THEME_MODE.DARK
: value === THEME_MODE.DARK;
const willFlipDarkMode = resolvedTargetIsDark !== currentIsDark;
const applyChange = (): void => {
if (value === THEME_MODE.SYSTEM) {
// Also push the resolved light/dark value through setTheme so the
// View Transition snapshot reflects the new theme synchronously.
// Otherwise the flip would only land via ThemeProvider's effect
// (setAutoSwitch → re-render → effect → setThemeState), which
// isn't guaranteed to run inside this flushSync batch and would
// cause the wipe to capture old → old followed by a post-animation snap.
setAutoSwitch(true);
setTheme(resolvedTargetIsDark ? THEME_MODE.DARK : THEME_MODE.LIGHT);
} else {
setAutoSwitch(false);
setTheme(value);
}
onApplied?.();
};
if (!willFlipDarkMode || !canAnimateThemeTransition()) {
applyChange();
return;
}
runThemeTransition(applyChange);
},
[theme, systemTheme, setTheme, setAutoSwitch],
);
}
export default useThemeSelection;

View File

@@ -1,3 +1,9 @@
import {
fillMissingXAxisTimestamps,
getXAxisTimestamps,
} from 'container/DashboardContainer/visualization/panels/utils';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
/**
* Checks if a value is invalid for plotting
*
@@ -52,6 +58,28 @@ export function normalizePlotValue(
return value as number;
}
/**
* Returns true if at most one entry in `values` is a valid plot value.
*
* Used to decide whether a series should render as a single point (drawStyle:
* Points) vs a line — a continuous line with only one visible sample is
* invisible to the user.
*/
export function hasSingleVisiblePoint(
values: ReadonlyArray<readonly [unknown, unknown]> | undefined,
): boolean {
let validPointCount = 0;
for (const [, rawValue] of values ?? []) {
if (!isInvalidPlotValue(rawValue)) {
validPointCount += 1;
if (validPointCount > 1) {
return false;
}
}
}
return true;
}
export interface SeriesSpanGapsOption {
spanGaps?: boolean | number;
}
@@ -226,3 +254,21 @@ export function applySpanGapsToAlignedData(
return [newX, ...transformedSeries] as uPlot.AlignedData;
}
/** * Transforms raw API response into aligned data format expected by uPlot.
*
* The API response contains multiple series of time-value pairs, each with its
* own set of timestamps. uPlot requires a single shared x-axis (timestamps)
* and separate y-value arrays for each series, aligned by index. This function
* extracts the unique sorted timestamps across all series and fills in missing
* values with null to maintain alignment.
*/
export const prepareChartData = (
apiResponse: MetricRangePayloadProps,
): uPlot.AlignedData => {
const seriesList = apiResponse?.data?.result || [];
const timestampArr = getXAxisTimestamps(seriesList);
const yAxisValuesArr = fillMissingXAxisTimestamps(timestampArr, seriesList);
return [timestampArr, ...yAxisValuesArr];
};

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

@@ -7,8 +7,8 @@ import {
useMemo,
useState,
} from 'react';
import { Input, Slider } from 'antd';
import type { SliderRangeProps } from 'antd/es/slider';
import { Input } from 'antd';
import { Slider } from '@signozhq/ui/slider';
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
import useDebouncedFn from 'hooks/useDebouncedFunction';
@@ -88,16 +88,15 @@ export function DurationSection(props: DurationProps): JSX.Element {
debouncedFunction(min, max);
};
const onRangeHandler: SliderRangeProps['onChange'] = ([min, max]) => {
const onRangeHandler = (value: number | number[]): void => {
const [min, max] = value as number[];
updateDurationFilter(min.toString(), max.toString());
};
const TipComponent = useCallback((value: undefined | number) => {
if (value === undefined) {
return <div />;
}
return <div>{`${value?.toString()}ms`}</div>;
}, []);
const TipComponent = useCallback(
(value: number) => <div>{`${value.toString()}ms`}</div>,
[],
);
return (
<div>
@@ -123,13 +122,14 @@ export function DurationSection(props: DurationProps): JSX.Element {
addonAfter="ms"
/>
</div>
<div>
<div className="duration-input-slider">
<Slider
min={0}
max={100000}
range
tooltip={{ formatter: TipComponent }}
onChange={([min, max]): void => {
onChange={(value): void => {
const [min, max] = value as number[];
onRangeSliderHandler([String(min), String(max)]);
}}
onAfterChange={onRangeHandler}

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

@@ -826,22 +826,3 @@ body.ai-assistant-panel-open {
:root {
--input-focus-outline-width: 0;
}
// Scoped to .theme-wipe-active (toggled on <html> in runThemeTransition) so
// these overrides don't leak into any unrelated view transitions added later.
// We disable the default UA crossfade so the JS-driven clip-path wipe is the
// only visible effect, and stack the new snapshot above the old.
html.theme-wipe-active {
&::view-transition-old(root),
&::view-transition-new(root) {
animation: none;
}
&::view-transition-old(root) {
z-index: 1;
}
&::view-transition-new(root) {
z-index: 2;
}
}

View File

@@ -0,0 +1,12 @@
/* AUTO GENERATED FILE - DO NOT EDIT - GENERATED FROM docs/config/web-settings.json */
export interface WebSettings {
appcues: Appcues;
posthog: Posthog;
}
export interface Appcues {
enabled: boolean;
}
export interface Posthog {
enabled: boolean;
}

View File

@@ -1,5 +1,6 @@
// eslint-disable-next-line no-restricted-imports
import { compose, Store } from 'redux';
import type { WebSettings } from 'types/generated/webSettings';
declare global {
interface Window {
@@ -7,6 +8,7 @@ declare global {
pylon: any;
Appcues: Record<string, any>;
__REDUX_DEVTOOLS_EXTENSION_COMPOSE__: typeof compose;
signozBootData?: { settings: WebSettings | null };
}
}

View File

@@ -0,0 +1,74 @@
export {};
type BootData = typeof import('../bootData');
function loadModule(settings?: object | null): BootData {
(window as any).signozBootData =
settings !== undefined ? { settings } : undefined;
let mod!: BootData;
jest.isolateModules(() => {
// oxlint-disable-next-line typescript-eslint/no-require-imports, typescript-eslint/no-var-requires
mod = require('../bootData');
});
return mod;
}
afterEach(() => {
delete (window as any).signozBootData;
});
describe('when window.signozBootData is absent', () => {
it('defaults posthog and appcues to enabled', () => {
const { bootSettings } = loadModule();
expect(bootSettings.posthog.enabled).toBe(true);
expect(bootSettings.appcues.enabled).toBe(true);
});
});
describe('when window.signozBootData.settings is null (injection failed)', () => {
it('defaults posthog and appcues to enabled', () => {
const { bootSettings } = loadModule(null);
expect(bootSettings.posthog.enabled).toBe(true);
expect(bootSettings.appcues.enabled).toBe(true);
});
});
describe('when window.signozBootData.settings is populated', () => {
it('reads posthog enabled: true', () => {
const { bootSettings } = loadModule({ posthog: { enabled: true } });
expect(bootSettings.posthog.enabled).toBe(true);
});
it('reads posthog enabled: false', () => {
const { bootSettings } = loadModule({ posthog: { enabled: false } });
expect(bootSettings.posthog.enabled).toBe(false);
});
it('reads appcues enabled: true', () => {
const { bootSettings } = loadModule({ appcues: { enabled: true } });
expect(bootSettings.appcues.enabled).toBe(true);
});
it('reads appcues enabled: false', () => {
const { bootSettings } = loadModule({ appcues: { enabled: false } });
expect(bootSettings.appcues.enabled).toBe(false);
});
it('missing sub-namespace defaults to enabled', () => {
const { bootSettings } = loadModule({ posthog: { enabled: false } });
expect(bootSettings.appcues.enabled).toBe(true);
});
});
describe('when window.signozBootData exists but settings is undefined', () => {
it('defaults posthog and appcues to enabled', () => {
(window as any).signozBootData = {};
let mod!: BootData;
jest.isolateModules(() => {
// oxlint-disable-next-line typescript-eslint/no-require-imports, typescript-eslint/no-var-requires
mod = require('../bootData');
});
expect(mod.bootSettings.posthog.enabled).toBe(true);
expect(mod.bootSettings.appcues.enabled).toBe(true);
});
});

View File

@@ -1,122 +0,0 @@
import {
canAnimateThemeTransition,
runThemeTransition,
THEME_WIPE_ACTIVE_CLASS,
} from '../themeTransition';
type StartVT = (cb: () => void) => {
ready: Promise<void>;
finished: Promise<void>;
};
const installStartViewTransition = (impl?: StartVT): jest.Mock => {
const defaultImpl: StartVT = (cb) => {
cb();
return { ready: Promise.resolve(), finished: Promise.resolve() };
};
const fn = jest.fn(impl ?? defaultImpl);
Object.defineProperty(document, 'startViewTransition', {
configurable: true,
writable: true,
value: fn,
});
return fn;
};
const removeStartViewTransition = (): void => {
Object.defineProperty(document, 'startViewTransition', {
configurable: true,
writable: true,
value: undefined,
});
};
const setReducedMotion = (matches: boolean): void => {
(window.matchMedia as jest.Mock) = jest
.fn()
.mockImplementation((query: string) => ({
matches: query === '(prefers-reduced-motion: reduce)' ? matches : false,
media: query,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
}));
};
describe('canAnimateThemeTransition', () => {
afterEach(() => {
removeStartViewTransition();
});
it('returns false when document.startViewTransition is unavailable', () => {
removeStartViewTransition();
setReducedMotion(false);
expect(canAnimateThemeTransition()).toBe(false);
});
it('returns false when prefers-reduced-motion is reduce', () => {
installStartViewTransition();
setReducedMotion(true);
expect(canAnimateThemeTransition()).toBe(false);
});
it('returns true when API is supported and motion is allowed', () => {
installStartViewTransition();
setReducedMotion(false);
expect(canAnimateThemeTransition()).toBe(true);
});
});
describe('runThemeTransition', () => {
afterEach(() => {
removeStartViewTransition();
document.documentElement.classList.remove(THEME_WIPE_ACTIVE_CLASS);
});
it('falls back to running applyChange directly when API is missing', () => {
removeStartViewTransition();
const applyChange = jest.fn();
runThemeTransition(applyChange);
expect(applyChange).toHaveBeenCalledTimes(1);
expect(
document.documentElement.classList.contains(THEME_WIPE_ACTIVE_CLASS),
).toBe(false);
});
it('invokes startViewTransition and runs applyChange inside its callback', () => {
const startVT = installStartViewTransition();
const applyChange = jest.fn();
runThemeTransition(applyChange);
expect(startVT).toHaveBeenCalledTimes(1);
expect(applyChange).toHaveBeenCalledTimes(1);
});
it('toggles the wipe-active class on <html> for the lifetime of the transition', async () => {
let resolveFinished: () => void = (): void => {};
installStartViewTransition((cb) => {
cb();
return {
ready: Promise.resolve(),
finished: new Promise<void>((resolve) => {
resolveFinished = resolve;
}),
};
});
runThemeTransition(() => undefined);
expect(
document.documentElement.classList.contains(THEME_WIPE_ACTIVE_CLASS),
).toBe(true);
resolveFinished();
await Promise.resolve();
await Promise.resolve();
expect(
document.documentElement.classList.contains(THEME_WIPE_ACTIVE_CLASS),
).toBe(false);
});
});

View File

@@ -0,0 +1,11 @@
import type { WebSettings } from 'types/generated/webSettings';
const raw = window.signozBootData?.settings as
| Partial<WebSettings>
| null
| undefined;
export const bootSettings: Readonly<WebSettings> = {
posthog: { enabled: raw?.posthog?.enabled ?? true },
appcues: { enabled: raw?.appcues?.enabled ?? true },
};

View File

@@ -1,67 +0,0 @@
import { flushSync } from 'react-dom';
const WIPE_DURATION_MS = 400;
const WIPE_EASING = 'ease-out';
// Toggled on <html> for the duration of the wipe so the CSS overrides
// (animation: none on ::view-transition-{old,new}(root)) don't leak into
// any future, unrelated view transitions in the app.
export const THEME_WIPE_ACTIVE_CLASS = 'theme-wipe-active';
type ViewTransition = {
ready: Promise<void>;
finished: Promise<void>;
};
type DocumentWithVT = Document & {
startViewTransition?: (callback: () => void) => ViewTransition;
};
export function canAnimateThemeTransition(): boolean {
const doc = document as DocumentWithVT;
if (typeof doc.startViewTransition !== 'function') {
return false;
}
return !window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
// Runs `applyChange` inside a View Transition and wipes the new theme in from
// left to right via a polygon clip-path on ::view-transition-new(root).
// Callers should gate on canAnimateThemeTransition() first; this is a safe
// no-animation fallback otherwise.
export function runThemeTransition(applyChange: () => void): void {
const doc = document as DocumentWithVT;
if (!doc.startViewTransition) {
applyChange();
return;
}
const root = document.documentElement;
root.classList.add(THEME_WIPE_ACTIVE_CLASS);
const transition = doc.startViewTransition(() => {
flushSync(applyChange);
});
const from = 'polygon(0 0, 0 0, 0 100%, 0 100%)';
const to = 'polygon(0 0, 100% 0, 100% 100%, 0 100%)';
transition.ready
.then(() =>
root.animate(
{ clipPath: [from, to] },
{
duration: WIPE_DURATION_MS,
easing: WIPE_EASING,
pseudoElement: '::view-transition-new(root)',
},
),
)
.catch(() => {
// Transition cancelled — applyChange has already run.
});
const cleanup = (): void => {
root.classList.remove(THEME_WIPE_ACTIVE_CLASS);
};
transition.finished.then(cleanup).catch(cleanup);
}

View File

@@ -23,6 +23,20 @@ function devBasePathPlugin(basePath: string): Plugin {
};
}
function devBootDataPlugin(env: Record<string, string>): Plugin {
return {
name: 'dev-boot-data',
apply: 'serve',
transformIndexHtml(html): string {
const settings = {
posthog: { enabled: env.VITE_POSTHOG_ENABLED !== 'false' },
appcues: { enabled: env.VITE_APPCUES_ENABLED !== 'false' },
};
return html.replaceAll('[[.Settings]]', JSON.stringify(settings));
},
};
}
function rawMarkdownPlugin(): Plugin {
return {
name: 'raw-markdown',
@@ -47,6 +61,7 @@ export default defineConfig(({ mode }): UserConfig => {
tsconfigPaths(),
rawMarkdownPlugin(),
devBasePathPlugin(basePath),
devBootDataPlugin(env),
react(),
createHtmlPlugin({
inject: {

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

@@ -42,7 +42,14 @@ func (m *MaintenanceMuter) Mutes(ctx context.Context, lset model.LabelSet) bool
}
now := time.Now()
for _, mw := range m.getMaintenances(ctx) {
if mw.ShouldSkip(ruleID, now) {
skip, err := mw.ShouldSkip(ruleID, now, lset)
if err != nil {
m.logger.ErrorContext(ctx, "failed to test maintenance window skip condition",
slog.String("maintenance_id", mw.ID.StringValue()),
slog.String("scope", mw.Scope),
slog.Any("error", err),
)
} else if skip {
return true
}
}
@@ -61,7 +68,14 @@ func (m *MaintenanceMuter) MutedBy(ctx context.Context, lset model.LabelSet) []s
var ids []string
now := time.Now()
for _, mw := range m.getMaintenances(ctx) {
if mw.ShouldSkip(ruleID, now) {
skip, err := mw.ShouldSkip(ruleID, now, lset)
if err != nil {
m.logger.ErrorContext(ctx, "failed to test maintenance window skip condition",
slog.String("maintenance_id", mw.ID.StringValue()),
slog.String("scope", mw.Scope),
slog.Any("error", err),
)
} else if skip {
ids = append(ids, mw.ID.String())
}
}

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

@@ -41,7 +41,7 @@ func TestEndToEndAlertManagerFlow(t *testing.T) {
Identifiable: types.Identifiable{
ID: valuer.GenerateUUID(),
},
Expression: `ruleId == "high-cpu-usage" && severity == "critical"`,
Expression: `ruleId = "high-cpu-usage" AND severity = "critical"`,
ExpressionKind: alertmanagertypes.RuleBasedExpression,
Name: "high-cpu-usage",
Description: "High CPU critical alerts to webhook",
@@ -53,7 +53,7 @@ func TestEndToEndAlertManagerFlow(t *testing.T) {
Identifiable: types.Identifiable{
ID: valuer.GenerateUUID(),
},
Expression: `ruleId == "high-cpu-usage" && severity == "warning"`,
Expression: `ruleId = "high-cpu-usage" AND severity = "warning"`,
ExpressionKind: alertmanagertypes.RuleBasedExpression,
Name: "high-cpu-usage",
Description: "High CPU warning alerts to webhook",
@@ -87,18 +87,25 @@ func TestEndToEndAlertManagerFlow(t *testing.T) {
err = notificationManager.SetNotificationConfig(orgID, "high-cpu-usage", &notifConfig)
require.NoError(t, err)
mwID := valuer.GenerateUUID()
activeSchedule := &alertmanagertypes.Schedule{
Timezone: "UTC",
StartTime: time.Now().Add(-time.Hour),
EndTime: time.Now().Add(time.Hour),
}
// mwRuleIDAndScope: only critical high-cpu-usage alerts.
mwRuleIDAndScope := valuer.GenerateUUID()
// mwRuleIDOnly: all high-cpu-usage alerts regardless of severity.
mwRuleIDOnly := valuer.GenerateUUID()
// mwScopeOnly: all critical alerts regardless of rule ID.
mwScopeOnly := valuer.GenerateUUID()
maintenanceStore := alertmanagertypestest.NewMockMaintenanceStore(t)
maintenanceStore.On("ListPlannedMaintenance", mock.Anything, orgID).Return(
[]*alertmanagertypes.PlannedMaintenance{{
ID: mwID,
Schedule: &alertmanagertypes.Schedule{
Timezone: "UTC",
StartTime: time.Now().Add(-time.Hour),
EndTime: time.Now().Add(time.Hour),
},
RuleIDs: []string{"high-cpu-usage"},
}}, nil,
[]*alertmanagertypes.PlannedMaintenance{
{ID: mwRuleIDAndScope, Schedule: activeSchedule, RuleIDs: []string{"high-cpu-usage"}, Scope: `severity = "critical"`},
{ID: mwRuleIDOnly, Schedule: activeSchedule, RuleIDs: []string{"high-cpu-usage"}},
{ID: mwScopeOnly, Schedule: activeSchedule, Scope: `severity = "critical"`},
}, nil,
)
srvCfg := NewConfig()
@@ -249,18 +256,42 @@ func TestEndToEndAlertManagerFlow(t *testing.T) {
require.Equal(t, "{__receiver__=\"webhook\"}:{cluster=\"prod-cluster\", instance=\"server-03\", ruleId=\"high-cpu-usage\"}", alertGroups[2].GroupKey)
})
t.Run("verify_muting", func(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, "/alerts", nil)
require.NoError(t, err)
params, err := alertmanagertypes.NewGettableAlertsParams(req)
require.NoError(t, err)
alerts, err := server.GetAlerts(ctx, params)
require.NoError(t, err)
req, err := http.NewRequest(http.MethodGet, "/alerts", nil)
require.NoError(t, err)
params, err := alertmanagertypes.NewGettableAlertsParams(req)
require.NoError(t, err)
alerts, err := server.GetAlerts(ctx, params)
require.NoError(t, err)
t.Run("verify_muting_ruleid_and_scope", func(t *testing.T) {
// Window with ruleID + scope mutes only alerts matching both.
for _, alert := range alerts {
if alert.Labels["ruleId"] == "high-cpu-usage" && alert.Labels["severity"] == "critical" {
require.Contains(t, alert.Status.MutedBy, mwRuleIDAndScope.String())
} else {
require.NotContains(t, alert.Status.MutedBy, mwRuleIDAndScope.String())
}
}
})
t.Run("verify_muting_ruleid_only", func(t *testing.T) {
// Window with ruleID but no scope mutes all severities for that rule.
for _, alert := range alerts {
if alert.Labels["ruleId"] == "high-cpu-usage" {
require.Equal(t, []string{mwID.String()}, alert.Status.MutedBy)
require.Contains(t, alert.Status.MutedBy, mwRuleIDOnly.String())
} else {
require.Empty(t, alert.Status.MutedBy)
require.NotContains(t, alert.Status.MutedBy, mwRuleIDOnly.String())
}
}
})
t.Run("verify_muting_scope_only", func(t *testing.T) {
// Window with scope but no ruleIDs mutes all critical alerts regardless of rule.
for _, alert := range alerts {
if alert.Labels["severity"] == "critical" {
require.Contains(t, alert.Status.MutedBy, mwScopeOnly.String())
} else {
require.NotContains(t, alert.Status.MutedBy, mwScopeOnly.String())
}
}
})

View File

@@ -89,6 +89,7 @@ func (r *maintenance) CreatePlannedMaintenance(ctx context.Context, maintenance
Description: maintenance.Description,
Schedule: maintenance.Schedule,
OrgID: claims.OrgID,
Scope: maintenance.Scope,
}
maintenanceRules := make([]*alertmanagertypes.StorablePlannedMaintenanceRule, 0)
@@ -123,7 +124,6 @@ func (r *maintenance) CreatePlannedMaintenance(ctx context.Context, maintenance
NewInsert().
Model(&maintenanceRules).
Exec(ctx)
if err != nil {
return err
}
@@ -141,6 +141,7 @@ func (r *maintenance) CreatePlannedMaintenance(ctx context.Context, maintenance
Description: storablePlannedMaintenance.Description,
Schedule: storablePlannedMaintenance.Schedule,
RuleIDs: maintenance.AlertIds,
Scope: maintenance.Scope,
CreatedAt: storablePlannedMaintenance.CreatedAt,
CreatedBy: storablePlannedMaintenance.CreatedBy,
UpdatedAt: storablePlannedMaintenance.UpdatedAt,
@@ -189,6 +190,7 @@ func (r *maintenance) UpdatePlannedMaintenance(ctx context.Context, maintenance
Description: maintenance.Description,
Schedule: maintenance.Schedule,
OrgID: claims.OrgID,
Scope: maintenance.Scope,
}
storablePlannedMaintenanceRules := make([]*alertmanagertypes.StorablePlannedMaintenanceRule, 0)
@@ -224,7 +226,6 @@ func (r *maintenance) UpdatePlannedMaintenance(ctx context.Context, maintenance
Model(new(alertmanagertypes.StorablePlannedMaintenanceRule)).
Where("planned_maintenance_id = ?", storablePlannedMaintenance.ID.StringValue()).
Exec(ctx)
if err != nil {
return err
}
@@ -241,7 +242,6 @@ func (r *maintenance) UpdatePlannedMaintenance(ctx context.Context, maintenance
}
return nil
})
if err != nil {
return err

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

@@ -3,7 +3,6 @@ package rulebasednotification
import (
"context"
"log/slog"
"strings"
"sync"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager"
@@ -235,65 +234,13 @@ func (r *provider) Match(ctx context.Context, orgID string, ruleID string, set m
return matchedChannels, nil
}
// convertLabelSetToEnv converts a flat label set with dotted keys into a nested map structure for expr env.
// when both a leaf and a deeper nested path exist (e.g. "foo" and "foo.bar"),
// the nested structure takes precedence. That means we will replace an existing leaf at any
// intermediate path with a map so we can materialize the deeper structure.
// TODO(srikanthccv): we need a better solution to handle this, remove the following
// when we update the expr to support dotted keys.
// convertLabelSetToEnv delegates to alertmanagertypes.ConvertLabelSetToEnv and
// logs when a key is a prefix of another (e.g. "foo" alongside "foo.bar").
func (r *provider) convertLabelSetToEnv(ctx context.Context, labelSet model.LabelSet) map[string]interface{} {
env := make(map[string]interface{})
logForReview := false
for lk, lv := range labelSet {
key := strings.TrimSpace(string(lk))
value := string(lv)
if strings.Contains(key, ".") {
parts := strings.Split(key, ".")
current := env
for i, raw := range parts {
part := strings.TrimSpace(raw)
last := i == len(parts)-1
if last {
if _, isMap := current[part].(map[string]interface{}); isMap {
logForReview = true
// deeper structure already exists; do not overwrite.
break
}
current[part] = value
break
}
// ensure a map so we can keep descending.
if nextMap, ok := current[part].(map[string]interface{}); ok {
current = nextMap
continue
}
// if absent or a leaf, replace it with a map.
newMap := make(map[string]interface{})
current[part] = newMap
current = newMap
}
continue
}
// if a map already sits here (due to nested keys), keep the map (nested wins).
if _, isMap := env[key].(map[string]interface{}); isMap {
logForReview = true
continue
}
env[key] = value
}
if logForReview {
env, conflict := alertmanagertypes.ConvertLabelSetToEnv(labelSet)
if conflict {
r.settings.Logger().InfoContext(ctx, "found label set with conflicting prefix dotted keys", slog.Any("labels", labelSet))
}
return env
}

View File

@@ -925,72 +925,3 @@ func TestProvider_CreateRoutes(t *testing.T) {
})
}
}
func TestConvertLabelSetToEnv(t *testing.T) {
tests := []struct {
name string
labelSet model.LabelSet
expected map[string]interface{}
}{
{
name: "simple keys",
labelSet: model.LabelSet{
"key1": "value1",
"key2": "value2",
},
expected: map[string]interface{}{
"key1": "value1",
"key2": "value2",
},
},
{
name: "nested keys",
labelSet: model.LabelSet{
"foo.bar": "value1",
"foo.baz": "value2",
},
expected: map[string]interface{}{
"foo": map[string]interface{}{
"bar": "value1",
"baz": "value2",
},
},
},
{
name: "conflict - nested structure wins",
labelSet: model.LabelSet{
"foo.bar.baz": "deep",
"foo.bar": "shallow",
},
expected: map[string]interface{}{
"foo": map[string]interface{}{
"bar": map[string]interface{}{
"baz": "deep",
},
},
},
},
{
name: "conflict - leaf value vs nested",
labelSet: model.LabelSet{
"foo.bar": "value",
"foo": "should_be_ignored",
},
expected: map[string]interface{}{
"foo": map[string]interface{}{
"bar": "value",
},
},
},
}
provider := &provider{
settings: factory.NewScopedProviderSettings(createTestProviderSettings(), "provider_test"),
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := provider.convertLabelSetToEnv(context.Background(), tt.labelSet)
assert.Equal(t, tt.expected, result)
})
}
}

View File

@@ -5,7 +5,7 @@ import (
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/tracedetailtypes"
"github.com/SigNoz/signoz/pkg/types/spantypes"
"github.com/gorilla/mux"
)
@@ -17,9 +17,9 @@ func (provider *provider) addTraceDetailRoutes(router *mux.Router) error {
Tags: []string{"tracedetail"},
Summary: "Get waterfall view for a trace",
Description: "Returns the waterfall view of spans for a given trace ID with tree structure, metadata, and windowed pagination",
Request: new(tracedetailtypes.PostableWaterfall),
Request: new(spantypes.PostableWaterfall),
RequestContentType: "application/json",
Response: new(tracedetailtypes.GettableWaterfallTrace),
Response: new(spantypes.GettableWaterfallTrace),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},

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