Compare commits

...

19 Commits

Author SHA1 Message Date
Ekansh Gupta
2f1bd624e6 Merge branch 'main' into thirdpartylistapifix 2025-09-28 22:11:29 +05:30
eKuG
a45f427533 feat: fixed the third party api based on the column availability in the database 2025-09-28 22:09:16 +05:30
Aditya Singh
3c3641493e fix: fix page offset in exceptions tab (#9184) 2025-09-28 12:45:56 +00:00
Amlan Kumar Nandy
411414fa45 chore: add routing polices page (#9198) 2025-09-27 21:02:14 +05:30
aniketio-ctrl
735b90722d chore(notification grouping): added custom grouping in signoz dispatcher (#8812) 2025-09-26 13:24:58 +00:00
Yunus M
8b485de584 chore: create a HOC to wrap components with ErrorBoundary (#9096)
* chore: create a HOC to wrap components with ErrorBoundary

* feat: move svg to public, use render from test-utils
2025-09-26 18:07:59 +05:30
Abhi kumar
d595dcc222 fix: added fix for passing activeLogId in query range in log context view (#9180)
* fix: added fix for passing activitylogId in query range in log context view

* chore: added tests
2025-09-26 13:17:38 +05:30
Ekansh Gupta
7ddaa84387 feat: add materialise ttl = 0 in set ttl v2 (#9189) 2025-09-26 05:44:13 +00:00
Niladri Adhikary
6d5f0adab9 fix: prevent panels with all queries disabled (#9093)
Signed-off-by: “niladrix719” <niladrix719@gmail.com>
2025-09-26 01:08:35 +00:00
primus-bot[bot]
2c19f0171f chore(release): bump to v0.96.1 (#9194)
Co-authored-by: primus-bot[bot] <171087277+primus-bot[bot]@users.noreply.github.com>
2025-09-25 20:47:24 +05:30
Abhi kumar
9a5bcb6b64 revert: removed changes done for cursor position jump fix (#9193) 2025-09-25 13:57:05 +00:00
Aditya Singh
96cdf21a92 Fix: Opening logs link broken (Pref framework) (#9182)
* fix: logs popover content logic extracted out

* fix: logs popover content in live view

* fix: destory popover on close

* feat: add logs format tests

* feat: minor refactor

* feat: test case refactor

* feat: remove menu refs in logs live view
2025-09-25 13:44:05 +00:00
Yunus M
1aa5f5d0e1 fix: extra content passed by consuming component (#9191) 2025-09-25 13:30:40 +00:00
Vishal Sharma
6ac812b5af chore: change update workspace URL to upgrade guide (#9178)
* chore: change update workspace URL to upgrade guide

* chore: change upgrade workspace url
2025-09-25 16:38:38 +05:30
Vikrant Gupta
0b4831ca04 chore(authz): bump up openfga version (#9175)
* chore(authz): bump up openfga version

* chore(authz): bump up openfga version

* chore(authz): bump up openfga version

---------

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2025-09-25 13:07:48 +05:30
primus-bot[bot]
340aa9ec21 chore(release): bump to v0.96.0 (#9179)
Co-authored-by: primus-bot[bot] <171087277+primus-bot[bot]@users.noreply.github.com>
Co-authored-by: Vikrant Gupta <vikrant@signoz.io>
Co-authored-by: Priyanshu Shrivastava <priyanshu@signoz.io>
2025-09-25 12:51:25 +05:30
Yunus M
5a47a4349b feat: hide feedback for non licensed users (#9176) 2025-09-25 12:40:21 +05:30
Ekansh Gupta
80f0c6dd92 feat: added cold storage in set ttl v2 method (#9151)
* feat: added cold storage in set ttl v2 method

* feat: standardised cold storage ttl to days

* feat: added coldstorage ttl in response structure of get api
2025-09-25 06:57:20 +00:00
Yunus M
c0acc69f87 fix: revert queryKey update to re-enable cancel run (#9105) 2025-09-25 12:05:02 +05:30
107 changed files with 7384 additions and 594 deletions

View File

@@ -42,7 +42,7 @@ services:
timeout: 5s
retries: 3
schema-migrator-sync:
image: signoz/signoz-schema-migrator:v0.129.5
image: signoz/signoz-schema-migrator:v0.129.6
container_name: schema-migrator-sync
command:
- sync
@@ -55,7 +55,7 @@ services:
condition: service_healthy
restart: on-failure
schema-migrator-async:
image: signoz/signoz-schema-migrator:v0.129.5
image: signoz/signoz-schema-migrator:v0.129.6
container_name: schema-migrator-async
command:
- async

4
.gitignore vendored
View File

@@ -230,6 +230,6 @@ poetry.toml
# LSP config files
pyrightconfig.json
# End of https://www.toptal.com/developers/gitignore/api/python
frontend/.cursor/rules/
# cursor files
frontend/.cursor/

View File

@@ -176,7 +176,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.95.0
image: signoz/signoz:v0.96.1
command:
- --config=/root/config/prometheus.yml
ports:
@@ -209,7 +209,7 @@ services:
retries: 3
otel-collector:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:v0.129.5
image: signoz/signoz-otel-collector:v0.129.6
command:
- --config=/etc/otel-collector-config.yaml
- --manager-config=/etc/manager-config.yaml
@@ -233,7 +233,7 @@ services:
- signoz
schema-migrator:
!!merge <<: *common
image: signoz/signoz-schema-migrator:v0.129.5
image: signoz/signoz-schema-migrator:v0.129.6
deploy:
restart_policy:
condition: on-failure

View File

@@ -117,7 +117,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.95.0
image: signoz/signoz:v0.96.1
command:
- --config=/root/config/prometheus.yml
ports:
@@ -150,7 +150,7 @@ services:
retries: 3
otel-collector:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:v0.129.5
image: signoz/signoz-otel-collector:v0.129.6
command:
- --config=/etc/otel-collector-config.yaml
- --manager-config=/etc/manager-config.yaml
@@ -176,7 +176,7 @@ services:
- signoz
schema-migrator:
!!merge <<: *common
image: signoz/signoz-schema-migrator:v0.129.5
image: signoz/signoz-schema-migrator:v0.129.6
deploy:
restart_policy:
condition: on-failure

View File

@@ -179,7 +179,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.95.0}
image: signoz/signoz:${VERSION:-v0.96.1}
container_name: signoz
command:
- --config=/root/config/prometheus.yml
@@ -213,7 +213,7 @@ services:
# TODO: support otel-collector multiple replicas. Nginx/Traefik for loadbalancing?
otel-collector:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.129.5}
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.129.6}
container_name: signoz-otel-collector
command:
- --config=/etc/otel-collector-config.yaml
@@ -239,7 +239,7 @@ services:
condition: service_healthy
schema-migrator-sync:
!!merge <<: *common
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.5}
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.6}
container_name: schema-migrator-sync
command:
- sync
@@ -250,7 +250,7 @@ services:
condition: service_healthy
schema-migrator-async:
!!merge <<: *db-depend
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.5}
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.6}
container_name: schema-migrator-async
command:
- async

View File

@@ -111,7 +111,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.95.0}
image: signoz/signoz:${VERSION:-v0.96.1}
container_name: signoz
command:
- --config=/root/config/prometheus.yml
@@ -144,7 +144,7 @@ services:
retries: 3
otel-collector:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.129.5}
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.129.6}
container_name: signoz-otel-collector
command:
- --config=/etc/otel-collector-config.yaml
@@ -166,7 +166,7 @@ services:
condition: service_healthy
schema-migrator-sync:
!!merge <<: *common
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.5}
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.6}
container_name: schema-migrator-sync
command:
- sync
@@ -178,7 +178,7 @@ services:
restart: on-failure
schema-migrator-async:
!!merge <<: *db-depend
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.5}
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.6}
container_name: schema-migrator-async
command:
- async

View File

@@ -325,17 +325,7 @@ func (s *Server) Stop(ctx context.Context) error {
return nil
}
func makeRulesManager(
ch baseint.Reader,
cache cache.Cache,
alertmanager alertmanager.Alertmanager,
sqlstore sqlstore.SQLStore,
telemetryStore telemetrystore.TelemetryStore,
prometheus prometheus.Prometheus,
orgGetter organization.Getter,
querier querier.Querier,
logger *slog.Logger,
) (*baserules.Manager, error) {
func makeRulesManager(ch baseint.Reader, cache cache.Cache, alertmanager alertmanager.Alertmanager, sqlstore sqlstore.SQLStore, telemetryStore telemetrystore.TelemetryStore, prometheus prometheus.Prometheus, orgGetter organization.Getter, querier querier.Querier, logger *slog.Logger) (*baserules.Manager, error) {
ruleStore := sqlrulestore.NewRuleStore(sqlstore)
maintenanceStore := sqlrulestore.NewMaintenanceStore(sqlstore)
// create manager opts

View File

@@ -387,6 +387,7 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (interface{}, erro
}
if smpl.IsMissing {
lb.Set(labels.AlertNameLabel, "[No data] "+r.Name())
lb.Set(labels.NoDataLabel, "true")
}
lbs := lb.Labels()

View File

@@ -3,6 +3,7 @@ import type { Config } from '@jest/types';
const USE_SAFE_NAVIGATE_MOCK_PATH = '<rootDir>/__mocks__/useSafeNavigate.ts';
const config: Config.InitialOptions = {
silent: true,
clearMocks: true,
coverageDirectory: 'coverage',
coverageReporters: ['text', 'cobertura', 'html', 'json-summary'],

View File

@@ -0,0 +1,81 @@
<svg
width="32"
height="32"
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19.11 16.8483L14.0369 17.7304C14.0369 17.7304 12.3481 22.0324 12.2437 22.3235C12.1392 22.6146 12.1437 23.0746 12.6037 23.0746C13.1881 23.0746 16.1546 23.0591 16.1546 23.0591C16.1546 23.0591 15.4346 26.5322 15.3924 26.8433C15.3502 27.1544 15.6835 27.4277 15.9768 27.1144C16.2701 26.8011 20.3121 21.6058 20.4877 21.3525C20.801 20.8992 20.4988 20.4814 20.1433 20.4614C19.7877 20.4414 17.4056 20.4925 17.4056 20.4925L19.11 16.8483Z"
fill="#FECA18"
/>
<path
d="M17.7589 17.4527C17.7589 17.4527 16.6279 19.9548 16.5856 20.097C16.4612 20.5192 17.0078 20.6903 17.1634 20.3481C17.3189 20.0037 18.6655 17.2194 18.6655 17.2194L17.7589 17.4527Z"
fill="#FDB900"
/>
<path
d="M12.8859 22.2592C13.1836 22.2503 15.7968 22.2436 16.0146 22.2281C16.4213 22.1969 16.4835 22.7591 16.0146 22.7591C15.528 22.7591 12.9637 22.768 12.8081 22.7747C12.4481 22.7925 12.3992 22.2747 12.8859 22.2592Z"
fill="#FDB900"
/>
<path
d="M14.9813 17.1127C14.9813 17.1127 13.6481 20.4592 13.5725 20.7103C13.2592 21.7591 14.3858 21.728 14.6836 21.1325C14.8302 20.837 16.2635 17.8016 16.3257 17.2527C16.3879 16.7061 14.9813 17.1127 14.9813 17.1127Z"
fill="#FFE36A"
/>
<path
d="M15.3347 21.0148C15.1436 20.8837 14.9125 20.9992 14.7725 21.2192C14.6325 21.4392 14.428 21.797 14.7414 21.9858C15.0236 22.1547 15.2724 21.8147 15.3835 21.657C15.4924 21.4992 15.6324 21.217 15.3347 21.0148Z"
fill="#FFE36A"
/>
<path
d="M17.6301 21.7326C17.1212 21.6237 16.9568 22.0459 16.8479 22.5459C16.739 23.0459 16.3302 24.6636 16.2546 25.0635C16.1457 25.6257 16.6612 25.7057 16.8812 25.188C17.0457 24.8013 17.759 23.057 17.8501 22.7792C17.9901 22.3592 18.1456 21.8437 17.6301 21.7326Z"
fill="#FFE36A"
/>
<path
d="M25.7585 12.0573C25.7585 12.0573 26.3363 4.28441 19.69 3.2978C13.7147 2.41118 12.5415 8.08421 12.5415 8.08421C12.5415 8.08421 10.2838 7.55757 8.66166 9.00639C7.05064 10.4463 7.17508 12.1507 7.17508 12.1507C7.17508 12.1507 3.20195 11.524 2.79531 14.935C2.41533 18.1215 7.11286 17.3282 7.11286 17.3282L29.4183 15.686C29.4183 15.686 29.9472 14.0106 28.2606 12.7462C27.2585 11.9929 25.7585 12.0573 25.7585 12.0573Z"
fill="#E4EAEE"
/>
<path
d="M13.7347 13.8196C13.9213 13.7574 14.9857 14.6662 18.0522 14.6951C22.2653 14.7373 25.4563 12.2552 25.4563 12.2552C25.4563 12.2552 25.5629 13.0108 25.2274 13.5485C24.8096 14.2151 24.163 14.3418 24.163 14.3418C24.163 14.3418 25.3318 15.1551 26.9362 15.0307C28.3828 14.9173 29.4294 14.4262 29.4294 14.4262C29.4294 14.4262 29.5694 15.1395 29.4916 15.8351C29.3361 17.2217 28.5228 17.7861 27.6251 17.8883C26.9651 17.9638 21.3276 17.9905 19.0122 18.0127C16.9256 18.0327 6.29285 18.2994 5.20624 18.1794C4.07963 18.0549 3.22412 17.3772 2.91303 16.4061C2.67526 15.6706 2.78859 15.1973 2.78859 15.1973C2.78859 15.1973 4.85959 15.7462 6.02175 15.6151C7.48167 15.4484 8.46162 14.5307 8.46162 14.5307C8.46162 14.5307 9.33713 15.0307 11.277 14.864C12.9458 14.7173 13.7347 13.8196 13.7347 13.8196Z"
fill="url(#paint0_radial_811_5475)"
/>
<path
d="M24.8653 18.2661C24.6386 18.1394 23.832 18.8927 23.3787 19.346C23.1009 19.6238 22.2165 20.3504 22.0965 21.0504C21.7988 22.7703 23.7698 23.1614 24.552 22.3637C25.143 21.7615 25.0364 20.586 25.0364 20.1904C25.0386 19.6416 25.1475 18.4216 24.8653 18.2661Z"
fill="#52C0EE"
/>
<path
d="M8.67058 19.1904C8.45504 19.0659 7.68174 19.7948 7.24621 20.237C6.97956 20.5058 6.13294 21.2103 6.01294 21.8947C5.71962 23.5723 7.5973 23.9657 8.34837 23.1924C8.91501 22.608 8.82168 21.4591 8.82391 21.0725C8.82613 20.537 8.93723 19.3459 8.67058 19.1904Z"
fill="#52C0EE"
/>
<path
d="M12.2548 24.1634C12.0126 24.0723 11.1971 24.8145 10.7438 25.2678C10.466 25.5456 9.64386 26.2566 9.52386 26.9566C9.2261 28.6765 11.1349 29.0832 11.9171 28.2854C12.5081 27.6832 12.4104 26.5077 12.4015 26.1122C12.3793 25.0812 12.4215 24.2256 12.2548 24.1634Z"
fill="#52C0EE"
/>
<path
d="M23.5054 20.4926C23.1943 20.3304 22.8165 20.3993 22.5765 20.8992C22.3365 21.3992 22.5343 21.797 22.7854 21.9103C23.0743 22.0436 23.432 21.9214 23.672 21.5147C23.912 21.1081 23.7654 20.6281 23.5054 20.4926Z"
fill="#B2E6FE"
/>
<path
d="M10.6682 26.4279C10.3482 26.3168 9.99715 26.4345 9.83716 26.9478C9.67717 27.4611 9.92382 27.8122 10.1794 27.8878C10.4749 27.9744 10.7993 27.8078 10.9727 27.3834C11.1438 26.9589 10.9349 26.5212 10.6682 26.4279Z"
fill="#B2E6FE"
/>
<path
d="M7.12613 21.3258C6.78837 21.2325 6.43283 21.3769 6.30395 21.9169C6.17507 22.4569 6.45061 22.8035 6.71948 22.8613C7.03058 22.9302 7.35278 22.7369 7.50389 22.288C7.65277 21.8436 7.41056 21.4036 7.12613 21.3258Z"
fill="#B2E6FE"
/>
<defs>
<radialGradient
id="paint0_radial_811_5475"
cx="0"
cy="0"
r="1"
gradientTransform="matrix(0.18837 -6.53799 9.79456 0.282554 16.401 18.5051)"
gradientUnits="userSpaceOnUse"
>
<stop offset="0.1934" stopColor="#FFE366" />
<stop offset="0.3305" stopColor="#EDDD82" />
<stop offset="0.5709" stopColor="#D0D4AD" />
<stop offset="0.7589" stopColor="#BFCFC7" />
<stop offset="0.8699" stopColor="#B8CDD1" />
</radialGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 5.4 KiB

View File

@@ -0,0 +1,36 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorResponseV2, ErrorV2Resp, SuccessResponseV2 } from 'types/api';
export interface CreateRoutingPolicyBody {
name: string;
expression: string;
actions: {
channels: string[];
};
description?: string;
}
export interface CreateRoutingPolicyResponse {
success: boolean;
message: string;
}
const createRoutingPolicy = async (
props: CreateRoutingPolicyBody,
): Promise<
SuccessResponseV2<CreateRoutingPolicyResponse> | ErrorResponseV2
> => {
try {
const response = await axios.post(`/notification-policy`, props);
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
return ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default createRoutingPolicy;

View File

@@ -0,0 +1,30 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorResponseV2, ErrorV2Resp, SuccessResponseV2 } from 'types/api';
export interface DeleteRoutingPolicyResponse {
success: boolean;
message: string;
}
const deleteRoutingPolicy = async (
routingPolicyId: string,
): Promise<
SuccessResponseV2<DeleteRoutingPolicyResponse> | ErrorResponseV2
> => {
try {
const response = await axios.delete(
`/notification-policy/${routingPolicyId}`,
);
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
return ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default deleteRoutingPolicy;

View File

@@ -0,0 +1,40 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorResponseV2, ErrorV2Resp, SuccessResponseV2 } from 'types/api';
export interface ApiRoutingPolicy {
id: string;
name: string;
expression: string;
description: string;
channels: string[];
createdAt: string;
updatedAt: string;
createdBy: string;
updatedBy: string;
}
export interface GetRoutingPoliciesResponse {
status: string;
data?: ApiRoutingPolicy[];
}
export const getRoutingPolicies = async (
signal?: AbortSignal,
headers?: Record<string, string>,
): Promise<SuccessResponseV2<GetRoutingPoliciesResponse> | ErrorResponseV2> => {
try {
const response = await axios.get('/notification-policy', {
signal,
headers,
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
return ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};

View File

@@ -0,0 +1,40 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorResponseV2, ErrorV2Resp, SuccessResponseV2 } from 'types/api';
export interface UpdateRoutingPolicyBody {
name: string;
expression: string;
actions: {
channels: string[];
};
description: string;
}
export interface UpdateRoutingPolicyResponse {
success: boolean;
message: string;
}
const updateRoutingPolicy = async (
id: string,
props: UpdateRoutingPolicyBody,
): Promise<
SuccessResponseV2<UpdateRoutingPolicyResponse> | ErrorResponseV2
> => {
try {
const response = await axios.put(`/notification-policy/${id}`, {
...props,
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
return ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default updateRoutingPolicy;

View File

@@ -87,7 +87,7 @@ function ChangelogModal({ changelog, onClose }: Props): JSX.Element {
const onClickUpdateWorkspace = (): void => {
window.open(
'https://github.com/SigNoz/signoz/releases',
'https://signoz.io/upgrade-path',
'_blank',
'noopener,noreferrer',
);

View File

@@ -91,7 +91,7 @@ describe('ChangelogModal', () => {
renderChangelog();
fireEvent.click(screen.getByText('Update my workspace'));
expect(window.open).toHaveBeenCalledWith(
'https://github.com/SigNoz/signoz/releases',
'https://signoz.io/upgrade-path',
'_blank',
'noopener,noreferrer',
);

View File

@@ -0,0 +1,117 @@
# withErrorBoundary HOC
A Higher-Order Component (HOC) that wraps React components with ErrorBoundary to provide error handling and recovery.
## Features
- **Automatic Error Catching**: Catches JavaScript errors in any component tree
- **Integration**: Automatically reports errors with context
- **Custom Fallback UI**: Supports custom error fallback components
- **Error Logging**: Optional custom error handlers for additional logging
- **TypeScript Support**: Fully typed with proper generics
- **Component Context**: Automatically adds component name to tags
## Basic Usage
```tsx
import { withErrorBoundary } from 'components/HOC';
// Wrap any component
const SafeComponent = withErrorBoundary(MyComponent);
// Use it like any other component
<SafeComponent prop1="value1" prop2="value2" />
```
## Advanced Usage
### Custom Fallback Component
```tsx
const CustomFallback = () => (
<div>
<h3>Oops! Something went wrong</h3>
<button onClick={() => window.location.reload()}>Reload</button>
</div>
);
const SafeComponent = withErrorBoundary(MyComponent, {
fallback: <CustomFallback />
});
```
### Custom Error Handler
```tsx
const SafeComponent = withErrorBoundary(MyComponent, {
onError: (error, componentStack, eventId) => {
console.error('Component error:', error);
// Send to analytics, logging service, etc.
}
});
```
### Sentry Configuration
```tsx
const SafeComponent = withErrorBoundary(MyComponent, {
sentryOptions: {
tags: {
section: 'dashboard',
priority: 'high',
feature: 'metrics'
},
level: 'error'
}
});
```
## API Reference
### `withErrorBoundary<P>(component, options?)`
#### Parameters
- `component: ComponentType<P>` - The React component to wrap
- `options?: WithErrorBoundaryOptions` - Configuration options
#### Options
```tsx
interface WithErrorBoundaryOptions {
/** Custom fallback component to render when an error occurs */
fallback?: ReactElement;
/** Custom error handler function */
onError?: (
error: unknown,
componentStack: string | undefined,
eventId: string
) => void;
/** Additional props to pass to the Sentry ErrorBoundary */
sentryOptions?: {
tags?: Record<string, string>;
level?: Sentry.SeverityLevel;
};
}
```
## When to Use
- **Critical Components**: Wrap important UI components that shouldn't crash the entire app
- **Third-party Integrations**: Wrap components that use external libraries
- **Data-heavy Components**: Wrap components that process complex data
- **Route Components**: Wrap page-level components to prevent navigation issues
## Best Practices
1. **Use Sparingly**: Don't wrap every component - focus on critical ones
2. **Meaningful Fallbacks**: Provide helpful fallback UI that guides users
3. **Log Errors**: Always implement error logging for debugging
4. **Component Names**: Ensure components have proper `displayName` for debugging
5. **Test Error Scenarios**: Test that your error boundaries work as expected
## Examples
See `withErrorBoundary.example.tsx` for complete usage examples.

View File

@@ -0,0 +1,211 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import withErrorBoundary, {
WithErrorBoundaryOptions,
} from '../withErrorBoundary';
// Mock dependencies before imports
jest.mock('@sentry/react', () => {
const ReactMock = jest.requireActual('react');
class MockErrorBoundary extends ReactMock.Component<
{
children: React.ReactNode;
fallback: React.ReactElement;
onError?: (error: Error, componentStack: string, eventId: string) => void;
beforeCapture?: (scope: {
setTag: (key: string, value: string) => void;
setLevel: (level: string) => void;
}) => void;
},
{ hasError: boolean }
> {
constructor(props: MockErrorBoundary['props']) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(): { hasError: boolean } {
return { hasError: true };
}
componentDidCatch(error: Error, errorInfo: { componentStack: string }): void {
const { beforeCapture, onError } = this.props;
if (beforeCapture) {
const mockScope = {
setTag: jest.fn(),
setLevel: jest.fn(),
};
beforeCapture(mockScope);
}
if (onError) {
onError(error, errorInfo.componentStack, 'mock-event-id');
}
}
render(): React.ReactNode {
const { hasError } = this.state;
const { fallback, children } = this.props;
if (hasError) {
return <div data-testid="error-boundary-fallback">{fallback}</div>;
}
return <div data-testid="app-error-boundary">{children}</div>;
}
}
return {
ErrorBoundary: MockErrorBoundary,
SeverityLevel: {
error: 'error',
warning: 'warning',
info: 'info',
},
};
});
jest.mock(
'../../../pages/ErrorBoundaryFallback/ErrorBoundaryFallback',
() =>
function MockErrorBoundaryFallback(): JSX.Element {
return (
<div data-testid="default-error-fallback">Default Error Fallback</div>
);
},
);
// Test component that can throw errors
interface TestComponentProps {
shouldThrow?: boolean;
message?: string;
}
function TestComponent({
shouldThrow = false,
message = 'Test Component',
}: TestComponentProps): JSX.Element {
if (shouldThrow) {
throw new Error('Test error');
}
return <div data-testid="test-component">{message}</div>;
}
TestComponent.defaultProps = {
shouldThrow: false,
message: 'Test Component',
};
// Test component with display name
function NamedComponent(): JSX.Element {
return <div data-testid="named-component">Named Component</div>;
}
NamedComponent.displayName = 'NamedComponent';
describe('withErrorBoundary', () => {
// Suppress console errors for cleaner test output
const originalError = console.error;
beforeAll(() => {
console.error = jest.fn();
});
afterAll(() => {
console.error = originalError;
});
beforeEach(() => {
jest.clearAllMocks();
});
it('should wrap component with ErrorBoundary and render successfully', () => {
// Arrange
const SafeComponent = withErrorBoundary(TestComponent);
// Act
render(<SafeComponent message="Hello World" />);
// Assert
expect(screen.getByTestId('app-error-boundary')).toBeInTheDocument();
expect(screen.getByTestId('test-component')).toBeInTheDocument();
expect(screen.getByText('Hello World')).toBeInTheDocument();
});
it('should render fallback UI when component throws error', () => {
// Arrange
const SafeComponent = withErrorBoundary(TestComponent);
// Act
render(<SafeComponent shouldThrow />);
// Assert
expect(screen.getByTestId('error-boundary-fallback')).toBeInTheDocument();
expect(screen.getByTestId('default-error-fallback')).toBeInTheDocument();
});
it('should render custom fallback component when provided', () => {
// Arrange
const customFallback = (
<div data-testid="custom-fallback">Custom Error UI</div>
);
const options: WithErrorBoundaryOptions = {
fallback: customFallback,
};
const SafeComponent = withErrorBoundary(TestComponent, options);
// Act
render(<SafeComponent shouldThrow />);
// Assert
expect(screen.getByTestId('error-boundary-fallback')).toBeInTheDocument();
expect(screen.getByTestId('custom-fallback')).toBeInTheDocument();
expect(screen.getByText('Custom Error UI')).toBeInTheDocument();
});
it('should call custom error handler when error occurs', () => {
// Arrange
const mockErrorHandler = jest.fn();
const options: WithErrorBoundaryOptions = {
onError: mockErrorHandler,
};
const SafeComponent = withErrorBoundary(TestComponent, options);
// Act
render(<SafeComponent shouldThrow />);
// Assert
expect(mockErrorHandler).toHaveBeenCalledWith(
expect.any(Error),
expect.any(String),
'mock-event-id',
);
expect(mockErrorHandler).toHaveBeenCalledTimes(1);
});
it('should set correct display name for debugging', () => {
// Arrange & Act
const SafeTestComponent = withErrorBoundary(TestComponent);
const SafeNamedComponent = withErrorBoundary(NamedComponent);
// Assert
expect(SafeTestComponent.displayName).toBe(
'withErrorBoundary(TestComponent)',
);
expect(SafeNamedComponent.displayName).toBe(
'withErrorBoundary(NamedComponent)',
);
});
it('should handle component without display name', () => {
// Arrange
function AnonymousComponent(): JSX.Element {
return <div>Anonymous</div>;
}
// Act
const SafeAnonymousComponent = withErrorBoundary(AnonymousComponent);
// Assert
expect(SafeAnonymousComponent.displayName).toBe(
'withErrorBoundary(AnonymousComponent)',
);
});
});

View File

@@ -0,0 +1,2 @@
export type { WithErrorBoundaryOptions } from './withErrorBoundary';
export { default as withErrorBoundary } from './withErrorBoundary';

View File

@@ -0,0 +1,143 @@
import { Button } from 'antd';
import { useState } from 'react';
import { withErrorBoundary } from './index';
/**
* Example component that can throw errors
*/
function ProblematicComponent(): JSX.Element {
const [shouldThrow, setShouldThrow] = useState(false);
if (shouldThrow) {
throw new Error('This is a test error from ProblematicComponent!');
}
return (
<div style={{ padding: '20px' }}>
<h3>Problematic Component</h3>
<p>This component can throw errors when the button is clicked.</p>
<Button type="primary" onClick={(): void => setShouldThrow(true)} danger>
Trigger Error
</Button>
</div>
);
}
/**
* Basic usage - wraps component with default error boundary
*/
export const SafeProblematicComponent = withErrorBoundary(ProblematicComponent);
/**
* Usage with custom fallback component
*/
function CustomErrorFallback(): JSX.Element {
return (
<div
style={{ padding: '20px', border: '1px solid red', borderRadius: '4px' }}
>
<h4 style={{ color: 'red' }}>Custom Error Fallback</h4>
<p>Something went wrong in this specific component!</p>
<Button onClick={(): void => window.location.reload()}>Reload Page</Button>
</div>
);
}
export const SafeProblematicComponentWithCustomFallback = withErrorBoundary(
ProblematicComponent,
{
fallback: <CustomErrorFallback />,
},
);
/**
* Usage with custom error handler
*/
export const SafeProblematicComponentWithErrorHandler = withErrorBoundary(
ProblematicComponent,
{
onError: (error, errorInfo) => {
console.error('Custom error handler:', error);
console.error('Error info:', errorInfo);
// You could also send to analytics, logging service, etc.
},
sentryOptions: {
tags: {
section: 'dashboard',
priority: 'high',
},
level: 'error',
},
},
);
/**
* Example of wrapping an existing component from the codebase
*/
function ExistingComponent({
title,
data,
}: {
title: string;
data: any[];
}): JSX.Element {
// This could be any existing component that might throw errors
return (
<div>
<h4>{title}</h4>
<ul>
{data.map((item, index) => (
// eslint-disable-next-line react/no-array-index-key
<li key={index}>{item.name}</li>
))}
</ul>
</div>
);
}
export const SafeExistingComponent = withErrorBoundary(ExistingComponent, {
sentryOptions: {
tags: {
component: 'ExistingComponent',
feature: 'data-display',
},
},
});
/**
* Usage examples in a container component
*/
export function ErrorBoundaryExamples(): JSX.Element {
const sampleData = [
{ name: 'Item 1' },
{ name: 'Item 2' },
{ name: 'Item 3' },
];
return (
<div style={{ padding: '20px' }}>
<h2>Error Boundary HOC Examples</h2>
<div style={{ marginBottom: '20px' }}>
<h3>1. Basic Usage</h3>
<SafeProblematicComponent />
</div>
<div style={{ marginBottom: '20px' }}>
<h3>2. With Custom Fallback</h3>
<SafeProblematicComponentWithCustomFallback />
</div>
<div style={{ marginBottom: '20px' }}>
<h3>3. With Custom Error Handler</h3>
<SafeProblematicComponentWithErrorHandler />
</div>
<div style={{ marginBottom: '20px' }}>
<h3>4. Wrapped Existing Component</h3>
<SafeExistingComponent title="Sample Data" data={sampleData} />
</div>
</div>
);
}

View File

@@ -0,0 +1,99 @@
import * as Sentry from '@sentry/react';
import { ComponentType, ReactElement } from 'react';
import ErrorBoundaryFallback from '../../pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
/**
* Configuration options for the ErrorBoundary HOC
*/
interface WithErrorBoundaryOptions {
/** Custom fallback component to render when an error occurs */
fallback?: ReactElement;
/** Custom error handler function */
onError?: (
error: unknown,
componentStack: string | undefined,
eventId: string,
) => void;
/** Additional props to pass to the ErrorBoundary */
sentryOptions?: {
tags?: Record<string, string>;
level?: Sentry.SeverityLevel;
};
}
/**
* Higher-Order Component that wraps a component with ErrorBoundary
*
* @param WrappedComponent - The component to wrap with error boundary
* @param options - Configuration options for the error boundary
*
* @example
* // Basic usage
* const SafeComponent = withErrorBoundary(MyComponent);
*
* @example
* // With custom fallback
* const SafeComponent = withErrorBoundary(MyComponent, {
* fallback: <div>Something went wrong!</div>
* });
*
* @example
* // With custom error handler
* const SafeComponent = withErrorBoundary(MyComponent, {
* onError: (error, errorInfo) => {
* console.error('Component error:', error, errorInfo);
* }
* });
*/
function withErrorBoundary<P extends Record<string, unknown>>(
WrappedComponent: ComponentType<P>,
options: WithErrorBoundaryOptions = {},
): ComponentType<P> {
const {
fallback = <ErrorBoundaryFallback />,
onError,
sentryOptions = {},
} = options;
function WithErrorBoundaryComponent(props: P): JSX.Element {
return (
<Sentry.ErrorBoundary
fallback={fallback}
beforeCapture={(scope): void => {
// Add component name to context
scope.setTag(
'component',
WrappedComponent.displayName || WrappedComponent.name || 'Unknown',
);
// Add any custom tags
if (sentryOptions.tags) {
Object.entries(sentryOptions.tags).forEach(([key, value]) => {
scope.setTag(key, value);
});
}
// Set severity level if provided
if (sentryOptions.level) {
scope.setLevel(sentryOptions.level);
}
}}
onError={onError}
>
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
<WrappedComponent {...props} />
</Sentry.ErrorBoundary>
);
}
// Set display name for debugging purposes
WithErrorBoundaryComponent.displayName = `withErrorBoundary(${
WrappedComponent.displayName || WrappedComponent.name || 'Component'
})`;
return WithErrorBoundaryComponent;
}
export default withErrorBoundary;
export type { WithErrorBoundaryOptions };

View File

@@ -2,6 +2,7 @@ import './HeaderRightSection.styles.scss';
import { Button, Popover } from 'antd';
import logEvent from 'api/common/logEvent';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { Globe, Inbox, SquarePen } from 'lucide-react';
import { useCallback, useState } from 'react';
import { useLocation } from 'react-router-dom';
@@ -20,13 +21,15 @@ function HeaderRightSection({
enableAnnouncements,
enableShare,
enableFeedback,
}: HeaderRightSectionProps): JSX.Element {
}: HeaderRightSectionProps): JSX.Element | null {
const location = useLocation();
const [openFeedbackModal, setOpenFeedbackModal] = useState(false);
const [openShareURLModal, setOpenShareURLModal] = useState(false);
const [openAnnouncementsModal, setOpenAnnouncementsModal] = useState(false);
const { isCloudUser, isEnterpriseSelfHostedUser } = useGetTenantLicense();
const handleOpenFeedbackModal = useCallback((): void => {
logEvent('Feedback: Clicked', {
page: location.pathname,
@@ -63,9 +66,11 @@ function HeaderRightSection({
setOpenShareURLModal(open);
};
const isLicenseEnabled = isEnterpriseSelfHostedUser || isCloudUser;
return (
<div className="header-right-section-container">
{enableFeedback && (
{enableFeedback && isLicenseEnabled && (
<Popover
rootClassName="header-section-popover-root"
className="shareable-link-popover"

View File

@@ -5,6 +5,7 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import logEvent from 'api/common/logEvent';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { useLocation } from 'react-router-dom';
import HeaderRightSection from '../HeaderRightSection';
@@ -44,8 +45,13 @@ jest.mock('../AnnouncementsModal', () => ({
),
}));
jest.mock('hooks/useGetTenantLicense', () => ({
useGetTenantLicense: jest.fn(),
}));
const mockLogEvent = logEvent as jest.Mock;
const mockUseLocation = useLocation as jest.Mock;
const mockUseGetTenantLicense = useGetTenantLicense as jest.Mock;
const defaultProps = {
enableAnnouncements: true,
@@ -61,6 +67,13 @@ describe('HeaderRightSection', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseLocation.mockReturnValue(mockLocation);
// Default to licensed user (Enterprise or Cloud)
mockUseGetTenantLicense.mockReturnValue({
isCloudUser: true,
isEnterpriseSelfHostedUser: false,
isCommunityUser: false,
isCommunityEnterpriseUser: false,
});
});
it('should render all buttons when all features are enabled', () => {
@@ -189,4 +202,84 @@ describe('HeaderRightSection', () => {
expect(screen.getByTestId('feedback-modal')).toBeInTheDocument();
expect(screen.queryByTestId('share-modal')).not.toBeInTheDocument();
});
it('should show feedback button for Cloud users when feedback is enabled', () => {
mockUseGetTenantLicense.mockReturnValue({
isCloudUser: true,
isEnterpriseSelfHostedUser: false,
isCommunityUser: false,
isCommunityEnterpriseUser: false,
});
render(<HeaderRightSection {...defaultProps} />);
const feedbackButton = document.querySelector('.lucide-square-pen');
expect(feedbackButton).toBeInTheDocument();
});
it('should show feedback button for Enterprise self-hosted users when feedback is enabled', () => {
mockUseGetTenantLicense.mockReturnValue({
isCloudUser: false,
isEnterpriseSelfHostedUser: true,
isCommunityUser: false,
isCommunityEnterpriseUser: false,
});
render(<HeaderRightSection {...defaultProps} />);
const feedbackButton = document.querySelector('.lucide-square-pen');
expect(feedbackButton).toBeInTheDocument();
});
it('should hide feedback button for Community users even when feedback is enabled', () => {
mockUseGetTenantLicense.mockReturnValue({
isCloudUser: false,
isEnterpriseSelfHostedUser: false,
isCommunityUser: true,
isCommunityEnterpriseUser: false,
});
render(<HeaderRightSection {...defaultProps} />);
const feedbackButton = document.querySelector('.lucide-square-pen');
expect(feedbackButton).not.toBeInTheDocument();
});
it('should hide feedback button for Community Enterprise users even when feedback is enabled', () => {
mockUseGetTenantLicense.mockReturnValue({
isCloudUser: false,
isEnterpriseSelfHostedUser: false,
isCommunityUser: false,
isCommunityEnterpriseUser: true,
});
render(<HeaderRightSection {...defaultProps} />);
const feedbackButton = document.querySelector('.lucide-square-pen');
expect(feedbackButton).not.toBeInTheDocument();
});
it('should render correct number of buttons when feedback is hidden due to license', () => {
mockUseGetTenantLicense.mockReturnValue({
isCloudUser: false,
isEnterpriseSelfHostedUser: false,
isCommunityUser: true,
isCommunityEnterpriseUser: false,
});
render(<HeaderRightSection {...defaultProps} />);
// Should have 2 buttons (announcements + share) instead of 3
const buttons = screen.getAllByRole('button');
expect(buttons).toHaveLength(2);
// Verify which buttons are present
expect(screen.getByRole('button', { name: /share/i })).toBeInTheDocument();
const inboxIcon = document.querySelector('.lucide-inbox');
expect(inboxIcon).toBeInTheDocument();
// Verify feedback button is not present
const feedbackIcon = document.querySelector('.lucide-square-pen');
expect(feedbackIcon).not.toBeInTheDocument();
});
});

View File

@@ -26,7 +26,7 @@ interface LogsFormatOptionsMenuProps {
config: OptionsMenuConfig;
}
export default function LogsFormatOptionsMenu({
function OptionsMenu({
items,
selectedOptionFormat,
config,
@@ -49,7 +49,6 @@ export default function LogsFormatOptionsMenu({
const [selectedValue, setSelectedValue] = useState<string | null>(null);
const listRef = useRef<HTMLDivElement>(null);
const initialMouseEnterRef = useRef<boolean>(false);
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
const onChange = useCallback(
(key: LogViewMode) => {
@@ -209,7 +208,7 @@ export default function LogsFormatOptionsMenu({
};
}, [selectedValue]);
const popoverContent = (
return (
<div
className={cx(
'nested-menu-container',
@@ -447,15 +446,30 @@ export default function LogsFormatOptionsMenu({
)}
</div>
);
}
function LogsFormatOptionsMenu({
items,
selectedOptionFormat,
config,
}: LogsFormatOptionsMenuProps): JSX.Element {
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
return (
<Popover
content={popoverContent}
content={
<OptionsMenu
items={items}
selectedOptionFormat={selectedOptionFormat}
config={config}
/>
}
trigger="click"
placement="bottomRight"
arrow={false}
open={isPopoverOpen}
onOpenChange={setIsPopoverOpen}
rootClassName="format-options-popover"
destroyTooltipOnHide
>
<Button
className="periscope-btn ghost"
@@ -465,3 +479,5 @@ export default function LogsFormatOptionsMenu({
</Popover>
);
}
export default LogsFormatOptionsMenu;

View File

@@ -0,0 +1,157 @@
import { FontSize } from 'container/OptionsMenu/types';
import { fireEvent, render, waitFor } from 'tests/test-utils';
import LogsFormatOptionsMenu from '../LogsFormatOptionsMenu';
const mockUpdateFormatting = jest.fn();
jest.mock('providers/preferences/sync/usePreferenceSync', () => ({
usePreferenceSync: (): any => ({
preferences: {
columns: [],
formatting: {
maxLines: 2,
format: 'table',
fontSize: 'small',
version: 1,
},
},
loading: false,
error: null,
updateColumns: jest.fn(),
updateFormatting: mockUpdateFormatting,
}),
}));
describe('LogsFormatOptionsMenu (unit)', () => {
beforeEach(() => {
mockUpdateFormatting.mockClear();
});
function setup(): {
getByTestId: ReturnType<typeof render>['getByTestId'];
findItemByLabel: (label: string) => Element | undefined;
formatOnChange: jest.Mock<any, any>;
maxLinesOnChange: jest.Mock<any, any>;
fontSizeOnChange: jest.Mock<any, any>;
} {
const items = [
{ key: 'raw', label: 'Raw', data: { title: 'max lines per row' } },
{ key: 'list', label: 'Default' },
{ key: 'table', label: 'Column', data: { title: 'columns' } },
];
const formatOnChange = jest.fn();
const maxLinesOnChange = jest.fn();
const fontSizeOnChange = jest.fn();
const { getByTestId } = render(
<LogsFormatOptionsMenu
items={items}
selectedOptionFormat="table"
config={{
format: { value: 'table', onChange: formatOnChange },
maxLines: { value: 2, onChange: maxLinesOnChange },
fontSize: { value: FontSize.SMALL, onChange: fontSizeOnChange },
addColumn: {
isFetching: false,
value: [],
options: [],
onFocus: jest.fn(),
onBlur: jest.fn(),
onSearch: jest.fn(),
onSelect: jest.fn(),
onRemove: jest.fn(),
},
}}
/>,
);
// Open the popover menu by default for each test
const formatButton = getByTestId('periscope-btn-format-options');
fireEvent.click(formatButton);
const getMenuItems = (): Element[] =>
Array.from(document.querySelectorAll('.menu-items .item'));
const findItemByLabel = (label: string): Element | undefined =>
getMenuItems().find((el) => (el.textContent || '').includes(label));
return {
getByTestId,
findItemByLabel,
formatOnChange,
maxLinesOnChange,
fontSizeOnChange,
};
}
// Covers: opens menu, changes format selection, updates max-lines, changes font size
it('opens and toggles format selection', async () => {
const { findItemByLabel, formatOnChange } = setup();
// Assert initial selection
const columnItem = findItemByLabel('Column') as Element;
expect(document.querySelectorAll('.menu-items .item svg')).toHaveLength(1);
expect(columnItem.querySelector('svg')).toBeInTheDocument();
// Change selection to 'Raw'
const rawItem = findItemByLabel('Raw') as Element;
fireEvent.click(rawItem as HTMLElement);
await waitFor(() => {
const rawEl = findItemByLabel('Raw') as Element;
expect(document.querySelectorAll('.menu-items .item svg')).toHaveLength(1);
expect(rawEl.querySelector('svg')).toBeInTheDocument();
});
expect(formatOnChange).toHaveBeenCalledWith('raw');
});
it('increments max-lines and calls onChange', async () => {
const { maxLinesOnChange } = setup();
// Increment max lines
const input = document.querySelector(
'.max-lines-per-row-input input',
) as HTMLInputElement;
const initial = Number(input.value);
const buttons = document.querySelectorAll(
'.max-lines-per-row-input .periscope-btn',
);
const incrementBtn = buttons[1] as HTMLElement;
fireEvent.click(incrementBtn);
await waitFor(() => {
expect(Number(input.value)).toBe(initial + 1);
});
await waitFor(() => {
expect(maxLinesOnChange).toHaveBeenCalledWith(initial + 1);
});
});
it('changes font size to MEDIUM and calls onChange', async () => {
const { fontSizeOnChange } = setup();
// Open font dropdown
const fontButton = document.querySelector(
'.font-size-container .value',
) as HTMLElement;
fireEvent.click(fontButton);
// Choose MEDIUM
const optionButtons = Array.from(
document.querySelectorAll('.font-size-dropdown .option-btn'),
);
const mediumBtn = optionButtons[1] as HTMLElement;
fireEvent.click(mediumBtn);
await waitFor(() => {
expect(
document.querySelectorAll('.font-size-dropdown .option-btn .icon'),
).toHaveLength(1);
expect(
(optionButtons[1] as Element).querySelector('.icon'),
).toBeInTheDocument();
});
await waitFor(() => {
expect(fontSizeOnChange).toHaveBeenCalledWith(FontSize.MEDIUM);
});
});
});

View File

@@ -97,7 +97,7 @@ function QuerySearch({
onRun?: (query: string) => void;
}): JSX.Element {
const isDarkMode = useIsDarkMode();
const [query, setQuery] = useState<string>('');
const [query, setQuery] = useState<string>(queryData.filter?.expression || '');
const [valueSuggestions, setValueSuggestions] = useState<any[]>([]);
const [activeKey, setActiveKey] = useState<string>('');
const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false);
@@ -108,10 +108,6 @@ function QuerySearch({
errors: [],
});
const [cursorPos, setCursorPos] = useState({ line: 0, ch: 0 });
const [isFocused, setIsFocused] = useState(false);
const [hasInteractedWithQB, setHasInteractedWithQB] = useState(false);
const handleQueryValidation = (newQuery: string): void => {
try {
const validationResponse = validateQuery(newQuery);
@@ -131,28 +127,13 @@ function QuerySearch({
useEffect(() => {
const newQuery = queryData.filter?.expression || '';
// Only update query from external source when editor is not focused
// When focused, just update the lastExternalQuery to track changes
// Only mark as external change if the query actually changed from external source
if (newQuery !== lastExternalQuery) {
setQuery(newQuery);
setIsExternalQueryChange(true);
setLastExternalQuery(newQuery);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [queryData.filter?.expression]);
useEffect(() => {
// Update the query when the editor is blurred and the query has changed
// Only call onChange if the editor has been focused before (not on initial mount)
if (
!isFocused &&
hasInteractedWithQB &&
query !== queryData.filter?.expression
) {
onChange(query);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isFocused]);
}, [queryData.filter?.expression, lastExternalQuery]);
// Validate query when it changes externally (from queryData)
useEffect(() => {
@@ -168,6 +149,9 @@ function QuerySearch({
const [showExamples] = useState(false);
const [cursorPos, setCursorPos] = useState({ line: 0, ch: 0 });
const [isFocused, setIsFocused] = useState(false);
const [
isFetchingCompleteValuesList,
setIsFetchingCompleteValuesList,
@@ -181,9 +165,6 @@ function QuerySearch({
const lastFetchedKeyRef = useRef<string>('');
const lastValueRef = useRef<string>('');
const isMountedRef = useRef<boolean>(true);
const [shouldRunQueryPostUpdate, setShouldRunQueryPostUpdate] = useState(
false,
);
const { handleRunQuery } = useQueryBuilder();
@@ -229,7 +210,6 @@ function QuerySearch({
return (): void => clearTimeout(timeoutId);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[isFocused],
);
@@ -575,6 +555,7 @@ function QuerySearch({
const handleChange = (value: string): void => {
setQuery(value);
onChange(value);
// Mark as internal change to avoid triggering external validation
setIsExternalQueryChange(false);
// Update lastExternalQuery to prevent external validation trigger
@@ -1238,25 +1219,6 @@ function QuerySearch({
</div>
);
// Effect to handle query run after update
useEffect(
() => {
// Only run the query post updating the filter expression.
// This runs the query in the next update cycle of react, when it's guaranteed that the query is updated.
// Because both the things are sequential and react batches the updates so it was still taking the old query.
if (shouldRunQueryPostUpdate) {
if (onRun && typeof onRun === 'function') {
onRun(query);
} else {
handleRunQuery();
}
setShouldRunQueryPostUpdate(false);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[shouldRunQueryPostUpdate, handleRunQuery, onRun],
);
return (
<div className="code-mirror-where-clause">
{editingMode && (
@@ -1331,7 +1293,6 @@ function QuerySearch({
theme={isDarkMode ? copilot : githubLight}
onChange={handleChange}
onUpdate={handleUpdate}
data-testid="query-where-clause-editor"
className={cx('query-where-clause-editor', {
isValid: validation.isValid === true,
hasErrors: validation.errors.length > 0,
@@ -1368,14 +1329,11 @@ function QuerySearch({
// and instead run a custom action
// Mod-Enter is usually Ctrl-Enter or Cmd-Enter based on OS
run: (): boolean => {
if (
onChange &&
typeof onChange === 'function' &&
query !== queryData.filter?.expression
) {
onChange(query);
if (onRun && typeof onRun === 'function') {
onRun(query);
} else {
handleRunQuery();
}
setShouldRunQueryPostUpdate(true);
return true;
},
},
@@ -1394,13 +1352,8 @@ function QuerySearch({
}}
onFocus={(): void => {
setIsFocused(true);
setHasInteractedWithQB(true);
}}
onBlur={handleBlur}
onCreateEditor={(view: EditorView): EditorView => {
editorRef.current = view;
return view;
}}
/>
{query && validation.isValid === false && !isFocused && (

View File

@@ -222,28 +222,6 @@ describe('QuerySearch', () => {
expect(screen.getByPlaceholderText(PLACEHOLDER_TEXT)).toBeInTheDocument();
});
it('calls onChange on blur after user edits', async () => {
const handleChange = jest.fn() as jest.MockedFunction<(v: string) => void>;
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<QuerySearch
onChange={handleChange}
queryData={initialQueriesMap.metrics.builder.queryData[0]}
dataSource={DataSource.METRICS}
/>,
);
const editor = screen.getByTestId(TESTID_EDITOR);
await user.click(editor);
await user.type(editor, SAMPLE_VALUE_TYPING_COMPLETE);
// Blur triggers validation + onChange (only if focused at least once and value changed)
editor.blur();
await waitFor(() => expect(handleChange).toHaveBeenCalledTimes(1));
expect(handleChange.mock.calls[0][0]).toContain("service.name = 'frontend'");
});
it('fetches key suggestions when typing a key (debounced)', async () => {
jest.useFakeTimers();
const advance = (ms: number): void => {

View File

@@ -1,6 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { createMemoryHistory } from 'history';
import { Router } from 'react-router-dom';
import { fireEvent, render, screen } from 'tests/test-utils';
import RouteTab from './index';
import { RouteTabProps } from './types';

View File

@@ -61,8 +61,6 @@ function RouteTab({
defaultActiveKey={currentRoute?.key || activeKey}
animated
items={items}
// eslint-disable-next-line react/jsx-props-no-spreading
{...rest}
tabBarExtraContent={
showRightSection && (
<HeaderRightSection
@@ -72,6 +70,8 @@ function RouteTab({
/>
)
}
// eslint-disable-next-line react/jsx-props-no-spreading ---- TODO: remove this once follow the linting rules
{...rest}
/>
);
}

View File

@@ -86,4 +86,7 @@ export const REACT_QUERY_KEY = {
SPAN_LOGS: 'SPAN_LOGS',
SPAN_BEFORE_LOGS: 'SPAN_BEFORE_LOGS',
SPAN_AFTER_LOGS: 'SPAN_AFTER_LOGS',
// Routing Policies Query Keys
GET_ROUTING_POLICIES: 'GET_ROUTING_POLICIES',
} as const;

View File

@@ -482,7 +482,7 @@ function AllErrors(): JSX.Element {
pagination={{
pageSize: getUpdatedPageSize,
responsive: true,
current: getUpdatedOffset / 10 + 1,
current: Math.floor(getUpdatedOffset / getUpdatedPageSize) + 1,
position: ['bottomLeft'],
total: errorCountResponse.data?.payload || 0,
}}

View File

@@ -1,6 +1,6 @@
import '@testing-library/jest-dom';
import { fireEvent, render, screen } from '@testing-library/react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { ENVIRONMENT } from 'constants/env';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
@@ -137,4 +137,70 @@ describe('Exceptions - All Errors', () => {
}),
);
});
describe('pagination edge cases', () => {
it('should navigate to page 2 when pageSize=100 and clicking next', async () => {
// Arrange: start with pageSize=100 and offset=0
render(
<Exceptions
initUrl={[
`/exceptions?pageSize=100&offset=0&order=ascending&orderParam=serviceName`,
]}
/>,
);
// Wait for initial load
await screen.findByText(/redis timeout/i);
const nextPageItem = screen.getByTitle('Next Page');
const nextPageButton = nextPageItem.querySelector(
'button',
) as HTMLButtonElement;
fireEvent.click(nextPageButton);
await waitFor(() => {
const qp = new URLSearchParams(window.location.search);
expect(qp.get('offset')).toBe('100');
});
const queryParams = new URLSearchParams(window.location.search);
expect(queryParams.get('pageSize')).toBe('100');
expect(queryParams.get('offset')).toBe('100');
});
it('initializes current page from URL (offset/pageSize)', async () => {
// offset=100, pageSize=100 => current page should be 2
render(
<Exceptions
initUrl={[
`/exceptions?pageSize=100&offset=100&order=ascending&orderParam=serviceName`,
]}
/>,
);
await screen.findByText(/redis timeout/i);
const activeItem = document.querySelector('.ant-pagination-item-active');
expect(activeItem?.textContent).toBe('2');
const qp = new URLSearchParams(window.location.search);
expect(qp.get('pageSize')).toBe('100');
expect(qp.get('offset')).toBe('100');
});
it('clicking a numbered page updates offset correctly', async () => {
// pageSize=100, click page 3 => offset = 200
render(
<Exceptions
initUrl={[
`/exceptions?pageSize=100&offset=0&order=ascending&orderParam=serviceName`,
]}
/>,
);
await screen.findByText(/redis timeout/i);
const page3Item = screen.getByTitle('3');
const page3Anchor = page3Item.querySelector('a') as HTMLAnchorElement;
fireEvent.click(page3Anchor);
await waitFor(() => {
const qp = new URLSearchParams(window.location.search);
expect(qp.get('offset')).toBe('200');
});
});
});
});

View File

@@ -1,6 +1,7 @@
import { LoadingOutlined } from '@ant-design/icons';
import { Spin, Switch, Table, Tooltip, Typography } from 'antd';
import { useNavigateToExplorer } from 'components/CeleryTask/useNavigateToExplorer';
import { withErrorBoundary } from 'components/ErrorBoundaryHOC';
import { DEFAULT_ENTITY_VERSION, ENTITY_VERSION_V4 } from 'constants/app';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import {
@@ -248,4 +249,4 @@ function TopErrors({
);
}
export default TopErrors;
export default withErrorBoundary(TopErrors);

View File

@@ -1,6 +1,6 @@
import './LiveLogsContainer.styles.scss';
import { Button, Switch, Typography } from 'antd';
import { Switch, Typography } from 'antd';
import LogsFormatOptionsMenu from 'components/LogsFormatOptionsMenu/LogsFormatOptionsMenu';
import { MAX_LOGS_LIST_SIZE } from 'constants/liveTail';
import { LOCALSTORAGE } from 'constants/localStorage';
@@ -8,10 +8,8 @@ import GoToTop from 'container/GoToTop';
import { useOptionsMenu } from 'container/OptionsMenu';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import useClickOutside from 'hooks/useClickOutside';
import useDebouncedFn from 'hooks/useDebouncedFunction';
import { useEventSourceEvent } from 'hooks/useEventSourceEvent';
import { Sliders } from 'lucide-react';
import { useEventSource } from 'providers/EventSource';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useLocation } from 'react-router-dom';
@@ -41,9 +39,6 @@ function LiveLogsContainer(): JSX.Element {
const batchedEventsRef = useRef<ILiveLogsLog[]>([]);
const [showFormatMenuItems, setShowFormatMenuItems] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const prevFilterExpressionRef = useRef<string | null>(null);
const { options, config } = useOptionsMenu({
@@ -73,18 +68,6 @@ function LiveLogsContainer(): JSX.Element {
},
];
const handleToggleShowFormatOptions = (): void =>
setShowFormatMenuItems(!showFormatMenuItems);
useClickOutside({
ref: menuRef,
onClickOutside: () => {
if (showFormatMenuItems) {
setShowFormatMenuItems(false);
}
},
});
const {
handleStartOpenConnection,
handleCloseConnection,
@@ -231,21 +214,11 @@ function LiveLogsContainer(): JSX.Element {
/>
</div>
<div className="format-options-container" ref={menuRef}>
<Button
className="periscope-btn ghost"
onClick={handleToggleShowFormatOptions}
icon={<Sliders size={14} />}
/>
{showFormatMenuItems && (
<LogsFormatOptionsMenu
items={formatItems}
selectedOptionFormat={options.format}
config={config}
/>
)}
</div>
<LogsFormatOptionsMenu
items={formatItems}
selectedOptionFormat={options.format}
config={config}
/>
</div>
{showLiveLogsFrequencyChart && (

View File

@@ -59,6 +59,7 @@ import {
Query,
TagFilter,
} from 'types/api/queryBuilder/queryBuilderData';
import { Filter } from 'types/api/v5/queryRange';
import { QueryDataV3 } from 'types/api/widgets/getQuery';
import { DataSource, LogsAggregatorOperator } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
@@ -171,6 +172,11 @@ function LogsExplorerViewsContainer({
return;
}
let updatedFilterExpression = listQuery.filter?.expression || '';
if (activeLogId) {
updatedFilterExpression = `${updatedFilterExpression} id <= '${activeLogId}'`.trim();
}
const modifiedQueryData: IBuilderQuery = {
...listQuery,
aggregateOperator: LogsAggregatorOperator.COUNT,
@@ -183,6 +189,10 @@ function LogsExplorerViewsContainer({
},
],
legend: '{{severity_text}}',
filter: {
...listQuery?.filter,
expression: updatedFilterExpression || '',
},
...(activeLogId && {
filters: {
...listQuery?.filters,
@@ -286,6 +296,7 @@ function LogsExplorerViewsContainer({
page: number;
pageSize: number;
filters: TagFilter;
filter: Filter;
},
): Query | null => {
if (!query) return null;
@@ -297,6 +308,7 @@ function LogsExplorerViewsContainer({
// Add filter for activeLogId if present
let updatedFilters = params.filters;
let updatedFilterExpression = params.filter?.expression || '';
if (activeLogId) {
updatedFilters = {
...params.filters,
@@ -315,6 +327,7 @@ function LogsExplorerViewsContainer({
],
op: 'AND',
};
updatedFilterExpression = `${updatedFilterExpression} id <= '${activeLogId}'`.trim();
}
// Create orderBy array based on orderDirection
@@ -336,6 +349,9 @@ function LogsExplorerViewsContainer({
...(listQuery || initialQueryBuilderFormValues),
...paginateData,
...(updatedFilters ? { filters: updatedFilters } : {}),
filter: {
expression: updatedFilterExpression || '',
},
...(selectedView === ExplorerViews.LIST
? { order: newOrderBy, orderBy: newOrderBy }
: { order: [] }),
@@ -368,7 +384,7 @@ function LogsExplorerViewsContainer({
if (isLimit) return;
if (logs.length < pageSize) return;
const { limit, filters } = listQuery;
const { limit, filters, filter } = listQuery;
const nextLogsLength = logs.length + pageSize;
@@ -379,6 +395,7 @@ function LogsExplorerViewsContainer({
const newRequestData = getRequestData(stagedQuery, {
filters: filters || { items: [], op: 'AND' },
filter: filter || { expression: '' },
page: page + 1,
pageSize: nextPageSize,
});
@@ -526,6 +543,7 @@ function LogsExplorerViewsContainer({
const newRequestData = getRequestData(stagedQuery, {
filters: listQuery?.filters || initialFilters,
filter: listQuery?.filter || { expression: '' },
page: 1,
pageSize,
});

View File

@@ -1,3 +1,4 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
import { useGetExplorerQueryRange } from 'hooks/queryBuilder/useGetExplorerQueryRange';
@@ -261,6 +262,68 @@ describe('LogsExplorerViews -', () => {
// Verify the total number of filters (original + 1 new activeLogId filter)
expect(firstQuery.filters?.items.length).toBe(expectedFiltersLength);
// Verify the filter expression
expect(firstQuery.filter?.expression).toBe(`id <= '${ACTIVE_LOG_ID}'`);
}
});
});
it('should update filter expression with activeLogId when present with existing filter expression', async () => {
// Mock useCopyLogLink to return an activeLogId
(useCopyLogLink as jest.Mock).mockReturnValue({
activeLogId: ACTIVE_LOG_ID,
});
// Create a custom QueryBuilderContext with an existing filter expression
const customContext = {
...mockQueryBuilderContextValue,
panelType: PANEL_TYPES.LIST,
stagedQuery: {
...mockQueryBuilderContextValue.stagedQuery,
builder: {
...mockQueryBuilderContextValue.stagedQuery.builder,
queryData: [
{
...mockQueryBuilderContextValue.stagedQuery.builder.queryData[0],
filter: { expression: "service = 'frontend'" },
},
],
},
},
};
lodsQueryServerRequest();
render(
<QueryBuilderContext.Provider value={customContext as any}>
<PreferenceContextProvider>
<LogsExplorerViews
selectedView={ExplorerViews.LIST}
setIsLoadingQueries={(): void => {}}
listQueryKeyRef={{ current: {} }}
chartQueryKeyRef={{ current: {} }}
setWarning={(): void => {}}
showLiveLogs={false}
/>
</PreferenceContextProvider>
</QueryBuilderContext.Provider>,
);
await waitFor(() => {
// Find the call made for LIST panel type (main logs list request)
const listCall = (useGetExplorerQueryRange as jest.Mock).mock.calls.find(
(call) => call[1] === PANEL_TYPES.LIST && call[0],
);
expect(listCall).toBeDefined();
if (listCall) {
const queryArg = listCall[0];
const firstQuery = queryArg.builder.queryData[0];
// It should append the activeLogId condition to existing expression
expect(firstQuery.filter?.expression).toBe(
"service = 'frontend' id <= 'test-log-id'",
);
}
});
});

View File

@@ -1,4 +1,3 @@
import { render, screen } from '@testing-library/react';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import ROUTES from 'constants/routes';
import * as useGetMetricsListHooks from 'hooks/metricsExplorer/useGetMetricsList';
@@ -7,6 +6,7 @@ import { QueryClient, QueryClientProvider } from 'react-query';
import { Provider } from 'react-redux';
import { useSearchParams } from 'react-router-dom-v5-compat';
import store from 'store';
import { render, screen } from 'tests/test-utils';
import Summary from '../Summary';
import { TreemapViewType } from '../types';

View File

@@ -0,0 +1,47 @@
import { Button, Modal, Typography } from 'antd';
import { Trash2, X } from 'lucide-react';
import { DeleteRoutingPolicyProps } from './types';
function DeleteRoutingPolicy({
handleClose,
handleDelete,
routingPolicy,
isDeletingRoutingPolicy,
}: DeleteRoutingPolicyProps): JSX.Element {
return (
<Modal
className="delete-policy-modal"
title={<span className="title">Delete Routing Policy</span>}
open
closable={false}
onCancel={handleClose}
footer={[
<Button
key="cancel"
onClick={handleClose}
className="cancel-btn"
icon={<X size={16} />}
disabled={isDeletingRoutingPolicy}
>
Cancel
</Button>,
<Button
key="submit"
icon={<Trash2 size={16} />}
onClick={handleDelete}
className="delete-btn"
disabled={isDeletingRoutingPolicy}
>
Delete Routing Policy
</Button>,
]}
>
<Typography.Text className="delete-text">
{`Are you sure you want to delete ${routingPolicy?.name} routing policy? Deleting a routing policy is irreversible and cannot be undone.`}
</Typography.Text>
</Modal>
);
}
export default DeleteRoutingPolicy;

View File

@@ -0,0 +1,118 @@
import './styles.scss';
import { PlusOutlined } from '@ant-design/icons';
import { Color } from '@signozhq/design-tokens';
import { Button, Flex, Input, Tooltip, Typography } from 'antd';
import { Search } from 'lucide-react';
import { useAppContext } from 'providers/App/App';
import { ChangeEvent, useMemo } from 'react';
import { USER_ROLES } from 'types/roles';
import DeleteRoutingPolicy from './DeleteRoutingPolicy';
import RoutingPolicyDetails from './RoutingPolicyDetails';
import RoutingPolicyList from './RoutingPolicyList';
import useRoutingPolicies from './useRoutingPolicies';
function RoutingPolicies(): JSX.Element {
const { user } = useAppContext();
const {
// Routing Policies
selectedRoutingPolicy,
routingPoliciesData,
isLoadingRoutingPolicies,
isErrorRoutingPolicies,
// Channels
channels,
isLoadingChannels,
isErrorChannels,
refreshChannels,
// Search
searchTerm,
setSearchTerm,
// Delete Modal
isDeleteModalOpen,
handleDeleteModalOpen,
handleDeleteModalClose,
handleDeleteRoutingPolicy,
isDeletingRoutingPolicy,
// Policy Details Modal
policyDetailsModalState,
handlePolicyDetailsModalClose,
handlePolicyDetailsModalOpen,
handlePolicyDetailsModalAction,
isPolicyDetailsModalActionLoading,
} = useRoutingPolicies();
const disableCreateButton = user?.role === USER_ROLES.VIEWER;
const tooltipTitle = useMemo(() => {
if (user?.role === USER_ROLES.VIEWER) {
return 'You need edit permissions to create a routing policy';
}
return '';
}, [user?.role]);
const handleSearch = (e: ChangeEvent<HTMLInputElement>): void => {
setSearchTerm(e.target.value || '');
};
return (
<div className="routing-policies-container">
<div className="routing-policies-content">
<Typography.Title className="title">Routing Policies</Typography.Title>
<Typography.Text className="subtitle">
Create and manage routing policies.
</Typography.Text>
<Flex className="toolbar">
<Input
placeholder="Search for a routing policy..."
prefix={<Search size={12} color={Color.BG_VANILLA_400} />}
value={searchTerm}
onChange={handleSearch}
/>
<Tooltip title={tooltipTitle}>
<Button
icon={<PlusOutlined />}
type="primary"
onClick={(): void => handlePolicyDetailsModalOpen('create', null)}
disabled={disableCreateButton}
>
New routing policy
</Button>
</Tooltip>
</Flex>
<br />
<RoutingPolicyList
routingPolicies={routingPoliciesData}
isRoutingPoliciesLoading={isLoadingRoutingPolicies}
isRoutingPoliciesError={isErrorRoutingPolicies}
handlePolicyDetailsModalOpen={handlePolicyDetailsModalOpen}
handleDeleteModalOpen={handleDeleteModalOpen}
/>
{policyDetailsModalState.isOpen && (
<RoutingPolicyDetails
routingPolicy={selectedRoutingPolicy}
closeModal={handlePolicyDetailsModalClose}
mode={policyDetailsModalState.mode}
channels={channels}
isErrorChannels={isErrorChannels}
isLoadingChannels={isLoadingChannels}
handlePolicyDetailsModalAction={handlePolicyDetailsModalAction}
isPolicyDetailsModalActionLoading={isPolicyDetailsModalActionLoading}
refreshChannels={refreshChannels}
/>
)}
{isDeleteModalOpen && (
<DeleteRoutingPolicy
isDeletingRoutingPolicy={isDeletingRoutingPolicy}
handleDelete={handleDeleteRoutingPolicy}
handleClose={handleDeleteModalClose}
routingPolicy={selectedRoutingPolicy}
/>
)}
</div>
</div>
);
}
export default RoutingPolicies;

View File

@@ -0,0 +1,208 @@
import {
Button,
Divider,
Flex,
Form,
Input,
Modal,
Select,
Typography,
} from 'antd';
import { useForm } from 'antd/lib/form/Form';
import ROUTES from 'constants/routes';
import { ModalTitle } from 'container/PipelinePage/PipelineListsView/styles';
import { useAppContext } from 'providers/App/App';
import { useMemo } from 'react';
import { USER_ROLES } from 'types/roles';
import { INITIAL_ROUTING_POLICY_DETAILS_FORM_STATE } from './constants';
import {
RoutingPolicyDetailsFormState,
RoutingPolicyDetailsProps,
} from './types';
function RoutingPolicyDetails({
closeModal,
mode,
channels,
isErrorChannels,
isLoadingChannels,
routingPolicy,
handlePolicyDetailsModalAction,
isPolicyDetailsModalActionLoading,
refreshChannels,
}: RoutingPolicyDetailsProps): JSX.Element {
const [form] = useForm();
const { user } = useAppContext();
const initialFormState = useMemo(() => {
if (mode === 'edit') {
return {
name: routingPolicy?.name || '',
expression: routingPolicy?.expression || '',
channels: routingPolicy?.channels || [],
description: routingPolicy?.description || '',
};
}
return INITIAL_ROUTING_POLICY_DETAILS_FORM_STATE;
}, [routingPolicy, mode]);
const modalTitle =
mode === 'edit' ? 'Edit routing policy' : 'Create routing policy';
const handleSave = (): void => {
handlePolicyDetailsModalAction(mode, {
name: form.getFieldValue('name'),
expression: form.getFieldValue('expression'),
channels: form.getFieldValue('channels'),
description: form.getFieldValue('description'),
});
};
const notificationChannelsNotFoundContent = (
<Flex justify="space-between">
<Flex gap={4} align="center">
<Typography.Text>No channels yet.</Typography.Text>
{user?.role === USER_ROLES.ADMIN ? (
<Typography.Text>
Create one
<Button
style={{ padding: '0 4px' }}
type="link"
onClick={(): void => {
window.open(ROUTES.CHANNELS_NEW, '_blank');
}}
>
here.
</Button>
</Typography.Text>
) : (
<Typography.Text>Please ask your admin to create one.</Typography.Text>
)}
</Flex>
<Button type="text" onClick={refreshChannels}>
Refresh
</Button>
</Flex>
);
return (
<Modal
title={<ModalTitle level={4}>{modalTitle}</ModalTitle>}
centered
open
className="create-policy-modal"
width={600}
onCancel={closeModal}
footer={null}
maskClosable={false}
>
<Divider plain />
<Form<RoutingPolicyDetailsFormState>
form={form}
initialValues={initialFormState}
onFinish={handleSave}
>
<div className="create-policy-container">
<div className="input-group">
<Typography.Text>Routing Policy Name</Typography.Text>
<Form.Item
name="name"
rules={[
{
required: true,
message: 'Please provide a name for the routing policy',
},
]}
>
<Input placeholder="e.g. Base routing policy..." />
</Form.Item>
</div>
<div className="input-group">
<Typography.Text>Description</Typography.Text>
<Form.Item
name="description"
rules={[
{
required: false,
},
]}
>
<Input.TextArea
placeholder="e.g. This is a routing policy that..."
autoSize={{ minRows: 1, maxRows: 6 }}
style={{ resize: 'none' }}
/>
</Form.Item>
</div>
<div className="input-group">
<Typography.Text>Expression</Typography.Text>
<Form.Item
name="expression"
rules={[
{
required: true,
message: 'Please provide an expression for the routing policy',
},
]}
>
<Input.TextArea
placeholder='e.g. service.name == "payment" && threshold.name == "critical"'
autoSize={{ minRows: 1, maxRows: 6 }}
style={{ resize: 'none' }}
/>
</Form.Item>
</div>
<div className="input-group">
<Typography.Text>Notification Channels</Typography.Text>
<Form.Item
name="channels"
rules={[
{
required: true,
message: 'Please select at least one notification channel',
},
]}
>
<Select
options={channels.map((channel) => ({
value: channel.name,
label: channel.name,
}))}
mode="multiple"
placeholder="Select notification channels"
showSearch
maxTagCount={3}
maxTagPlaceholder={(omittedValues): string =>
`+${omittedValues.length} more`
}
maxTagTextLength={10}
filterOption={(input, option): boolean =>
option?.label?.toLowerCase().includes(input.toLowerCase()) || false
}
status={isErrorChannels ? 'error' : undefined}
disabled={isLoadingChannels}
notFoundContent={notificationChannelsNotFoundContent}
/>
</Form.Item>
</div>
</div>
<Flex className="create-policy-footer" justify="space-between">
<Button onClick={closeModal} disabled={isPolicyDetailsModalActionLoading}>
Cancel
</Button>
<Button
type="primary"
htmlType="submit"
loading={isPolicyDetailsModalActionLoading}
disabled={isPolicyDetailsModalActionLoading}
>
Save Routing Policy
</Button>
</Flex>
</Form>
</Modal>
);
}
export default RoutingPolicyDetails;

View File

@@ -0,0 +1,73 @@
import { Table, TableProps, Typography } from 'antd';
import { useMemo } from 'react';
import RoutingPolicyListItem from './RoutingPolicyListItem';
import { RoutingPolicy, RoutingPolicyListProps } from './types';
function RoutingPolicyList({
routingPolicies,
isRoutingPoliciesLoading,
isRoutingPoliciesError,
handlePolicyDetailsModalOpen,
handleDeleteModalOpen,
}: RoutingPolicyListProps): JSX.Element {
const columns: TableProps<RoutingPolicy>['columns'] = [
{
title: 'Routing Policy',
key: 'routingPolicy',
render: (data: RoutingPolicy): JSX.Element => (
<RoutingPolicyListItem
routingPolicy={data}
handlePolicyDetailsModalOpen={handlePolicyDetailsModalOpen}
handleDeleteModalOpen={handleDeleteModalOpen}
/>
),
},
];
const localeEmptyState = useMemo(
() => (
<div className="no-routing-policies-message-container">
{isRoutingPoliciesError ? (
<img src="/Icons/awwSnap.svg" alt="aww-snap" className="error-state-svg" />
) : (
<img
src="/Icons/emptyState.svg"
alt="thinking-emoji"
className="empty-state-svg"
/>
)}
{isRoutingPoliciesError ? (
<Typography.Text>
Something went wrong while fetching routing policies.
</Typography.Text>
) : (
<Typography.Text>No routing policies found.</Typography.Text>
)}
</div>
),
[isRoutingPoliciesError],
);
return (
<Table<RoutingPolicy>
columns={columns}
className="routing-policies-table"
bordered={false}
dataSource={routingPolicies}
loading={isRoutingPoliciesLoading}
showHeader={false}
rowKey="id"
pagination={{
pageSize: 5,
showSizeChanger: false,
hideOnSinglePage: true,
}}
locale={{
emptyText: isRoutingPoliciesLoading ? null : localeEmptyState,
}}
/>
);
}
export default RoutingPolicyList;

View File

@@ -0,0 +1,137 @@
import { Color } from '@signozhq/design-tokens';
import { Collapse, Flex, Tag, Typography } from 'antd';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { PenLine, Trash2 } from 'lucide-react';
import { useAppContext } from 'providers/App/App';
import { useTimezone } from 'providers/Timezone';
import { USER_ROLES } from 'types/roles';
import {
PolicyListItemContentProps,
PolicyListItemHeaderProps,
RoutingPolicyListItemProps,
} from './types';
function PolicyListItemHeader({
name,
handleEdit,
handleDelete,
}: PolicyListItemHeaderProps): JSX.Element {
const { user } = useAppContext();
const isEditEnabled = user?.role !== USER_ROLES.VIEWER;
return (
<Flex className="policy-list-item-header" justify="space-between">
<Typography>{name}</Typography>
{isEditEnabled && (
<div className="action-btn">
<PenLine
size={14}
onClick={(e): void => {
e.preventDefault();
e.stopPropagation();
handleEdit();
}}
data-testid="edit-routing-policy"
/>
<Trash2
size={14}
color={Color.BG_CHERRY_500}
onClick={(e): void => {
e.preventDefault();
e.stopPropagation();
handleDelete();
}}
data-testid="delete-routing-policy"
/>
</div>
)}
</Flex>
);
}
function PolicyListItemContent({
routingPolicy,
}: PolicyListItemContentProps): JSX.Element {
const { formatTimezoneAdjustedTimestamp } = useTimezone();
return (
<div className="policy-list-item-content">
<div className="policy-list-item-content-row">
<Typography>Created by</Typography>
<Typography>{routingPolicy.createdBy}</Typography>
</div>
<div className="policy-list-item-content-row">
<Typography>Created on</Typography>
<Typography>
{routingPolicy.createdAt
? formatTimezoneAdjustedTimestamp(
routingPolicy.createdAt,
DATE_TIME_FORMATS.MONTH_DATETIME,
)
: '-'}
</Typography>
</div>
<div className="policy-list-item-content-row">
<Typography>Updated by</Typography>
<Typography>{routingPolicy.updatedBy || '-'}</Typography>
</div>
<div className="policy-list-item-content-row">
<Typography>Updated on</Typography>
<Typography>
{routingPolicy.updatedAt
? formatTimezoneAdjustedTimestamp(
routingPolicy.updatedAt,
DATE_TIME_FORMATS.MONTH_DATETIME,
)
: '-'}
</Typography>
</div>
<div className="policy-list-item-content-row">
<Typography>Expression</Typography>
<Typography>{routingPolicy.expression}</Typography>
</div>
<div className="policy-list-item-content-row">
<Typography>Description</Typography>
<Typography>{routingPolicy.description || '-'}</Typography>
</div>
<div className="policy-list-item-content-row">
<Typography>Channels</Typography>
<div>
{routingPolicy.channels.map((channel) => (
<Tag key={channel}>{channel}</Tag>
))}
</div>
</div>
</div>
);
}
function RoutingPolicyListItem({
routingPolicy,
handlePolicyDetailsModalOpen,
handleDeleteModalOpen,
}: RoutingPolicyListItemProps): JSX.Element {
return (
<Collapse accordion className="policy-list-item">
<Collapse.Panel
header={
<PolicyListItemHeader
name={routingPolicy.name}
handleEdit={(): void =>
handlePolicyDetailsModalOpen('edit', routingPolicy)
}
handleDelete={(): void => handleDeleteModalOpen(routingPolicy)}
/>
}
key={routingPolicy.id}
>
<PolicyListItemContent routingPolicy={routingPolicy} />
</Collapse.Panel>
</Collapse>
);
}
export default RoutingPolicyListItem;

View File

@@ -0,0 +1,81 @@
import { fireEvent, render, screen } from '@testing-library/react';
import DeleteRoutingPolicy from '../DeleteRoutingPolicy';
import { MOCK_ROUTING_POLICY_1 } from './testUtils';
const mockRoutingPolicy = MOCK_ROUTING_POLICY_1;
const mockHandleDelete = jest.fn();
const mockHandleClose = jest.fn();
const DELETE_BUTTON_TEXT = 'Delete Routing Policy';
const CANCEL_BUTTON_TEXT = 'Cancel';
describe('DeleteRoutingPolicy', () => {
it('renders base layout with routing policy', () => {
render(
<DeleteRoutingPolicy
routingPolicy={mockRoutingPolicy}
isDeletingRoutingPolicy={false}
handleDelete={mockHandleDelete}
handleClose={mockHandleClose}
/>,
);
expect(
screen.getByRole('dialog', { name: DELETE_BUTTON_TEXT }),
).toBeInTheDocument();
expect(
screen.getByText(
`Are you sure you want to delete ${mockRoutingPolicy.name} routing policy? Deleting a routing policy is irreversible and cannot be undone.`,
),
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: CANCEL_BUTTON_TEXT }),
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: DELETE_BUTTON_TEXT }),
).toBeInTheDocument();
});
it('should call handleDelete when delete button is clicked', () => {
render(
<DeleteRoutingPolicy
routingPolicy={mockRoutingPolicy}
isDeletingRoutingPolicy={false}
handleDelete={mockHandleDelete}
handleClose={mockHandleClose}
/>,
);
fireEvent.click(screen.getByRole('button', { name: DELETE_BUTTON_TEXT }));
expect(mockHandleDelete).toHaveBeenCalled();
});
it('should call handleClose when cancel button is clicked', () => {
render(
<DeleteRoutingPolicy
routingPolicy={mockRoutingPolicy}
isDeletingRoutingPolicy={false}
handleDelete={mockHandleDelete}
handleClose={mockHandleClose}
/>,
);
fireEvent.click(screen.getByRole('button', { name: CANCEL_BUTTON_TEXT }));
expect(mockHandleClose).toHaveBeenCalled();
});
it('should be disabled when deleting routing policy', () => {
render(
<DeleteRoutingPolicy
routingPolicy={mockRoutingPolicy}
isDeletingRoutingPolicy
handleDelete={mockHandleDelete}
handleClose={mockHandleClose}
/>,
);
expect(
screen.getByRole('button', { name: DELETE_BUTTON_TEXT }),
).toBeDisabled();
expect(
screen.getByRole('button', { name: CANCEL_BUTTON_TEXT }),
).toBeDisabled();
});
});

View File

@@ -0,0 +1,126 @@
import { fireEvent, render, screen } from '@testing-library/react';
import * as appHooks from 'providers/App/App';
import RoutingPolicies from '../RoutingPolicies';
import * as routingPoliciesHooks from '../useRoutingPolicies';
import {
getAppContextMockState,
getUseRoutingPoliciesMockData,
MOCK_ROUTING_POLICY_1,
} from './testUtils';
const ROUTING_POLICY_DETAILS_TEST_ID = 'routing-policy-details';
jest.spyOn(appHooks, 'useAppContext').mockReturnValue(getAppContextMockState());
jest.mock('../RoutingPolicyList', () => ({
__esModule: true,
default: jest.fn(() => (
<div data-testid="routing-policy-list">RoutingPolicyList</div>
)),
}));
jest.mock('../RoutingPolicyDetails', () => ({
__esModule: true,
default: jest.fn(() => (
<div data-testid="routing-policy-details">RoutingPolicyDetails</div>
)),
}));
jest.mock('../DeleteRoutingPolicy', () => ({
__esModule: true,
default: jest.fn(() => (
<div data-testid="delete-routing-policy">DeleteRoutingPolicy</div>
)),
}));
const mockHandleSearch = jest.fn();
const mockHandlePolicyDetailsModalOpen = jest.fn();
jest.spyOn(routingPoliciesHooks, 'default').mockReturnValue(
getUseRoutingPoliciesMockData({
setSearchTerm: mockHandleSearch,
handlePolicyDetailsModalOpen: mockHandlePolicyDetailsModalOpen,
}),
);
describe('RoutingPolicies', () => {
it('should render components properly', () => {
render(<RoutingPolicies />);
expect(screen.getByText('Routing Policies')).toBeInTheDocument();
expect(
screen.getByText('Create and manage routing policies.'),
).toBeInTheDocument();
expect(
screen.getByPlaceholderText('Search for a routing policy...'),
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /New routing policy/ }),
).toBeInTheDocument();
expect(screen.getByTestId('routing-policy-list')).toBeInTheDocument();
expect(
screen.queryByTestId(ROUTING_POLICY_DETAILS_TEST_ID),
).not.toBeInTheDocument();
expect(screen.queryByTestId('delete-routing-policy')).not.toBeInTheDocument();
});
it('should enable the "New routing policy" button for users with ADMIN role', () => {
render(<RoutingPolicies />);
expect(
screen.getByRole('button', { name: /New routing policy/ }),
).toBeEnabled();
});
it('should disable the "New routing policy" button for users with VIEWER role', () => {
jest
.spyOn(appHooks, 'useAppContext')
.mockReturnValueOnce(getAppContextMockState({ role: 'VIEWER' }));
render(<RoutingPolicies />);
expect(
screen.getByRole('button', { name: /New routing policy/ }),
).toBeDisabled();
});
it('filters routing policies by search term', () => {
render(<RoutingPolicies />);
const searchInput = screen.getByPlaceholderText(
'Search for a routing policy...',
);
fireEvent.change(searchInput, {
target: { value: MOCK_ROUTING_POLICY_1.name },
});
expect(mockHandleSearch).toHaveBeenCalledWith(MOCK_ROUTING_POLICY_1.name);
});
it('clicking on the "New routing policy" button opens the policy details modal', () => {
render(<RoutingPolicies />);
const newRoutingPolicyButton = screen.getByRole('button', {
name: /New routing policy/,
});
fireEvent.click(newRoutingPolicyButton);
expect(mockHandlePolicyDetailsModalOpen).toHaveBeenCalledWith('create', null);
});
it('policy details modal is open based on modal state', () => {
jest.spyOn(routingPoliciesHooks, 'default').mockReturnValue(
getUseRoutingPoliciesMockData({
policyDetailsModalState: {
mode: 'create',
isOpen: true,
},
}),
);
render(<RoutingPolicies />);
expect(
screen.getByTestId(ROUTING_POLICY_DETAILS_TEST_ID),
).toBeInTheDocument();
});
it('delete modal is open based on modal state', () => {
jest.spyOn(routingPoliciesHooks, 'default').mockReturnValue(
getUseRoutingPoliciesMockData({
isDeleteModalOpen: true,
}),
);
render(<RoutingPolicies />);
expect(screen.getByTestId('delete-routing-policy')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,89 @@
import { render, screen } from '@testing-library/react';
import RoutingPoliciesList from '../RoutingPolicyList';
import { RoutingPolicyListItemProps } from '../types';
import { getUseRoutingPoliciesMockData } from './testUtils';
const useRoutingPolicesMockData = getUseRoutingPoliciesMockData();
const mockHandlePolicyDetailsModalOpen = jest.fn();
const mockHandleDeleteModalOpen = jest.fn();
jest.mock('../RoutingPolicyListItem', () => ({
__esModule: true,
default: jest.fn(({ routingPolicy }: RoutingPolicyListItemProps) => (
<div data-testid="routing-policy-list-item">{routingPolicy.name}</div>
)),
}));
const ROUTING_POLICY_LIST_ITEM_TEST_ID = 'routing-policy-list-item';
describe('RoutingPoliciesList', () => {
it('renders base layout with routing policies', () => {
render(
<RoutingPoliciesList
routingPolicies={useRoutingPolicesMockData.routingPoliciesData}
isRoutingPoliciesLoading={
useRoutingPolicesMockData.isLoadingRoutingPolicies
}
isRoutingPoliciesError={useRoutingPolicesMockData.isErrorRoutingPolicies}
handlePolicyDetailsModalOpen={mockHandlePolicyDetailsModalOpen}
handleDeleteModalOpen={mockHandleDeleteModalOpen}
/>,
);
const routingPolicyItems = screen.getAllByTestId(
ROUTING_POLICY_LIST_ITEM_TEST_ID,
);
expect(routingPolicyItems).toHaveLength(2);
expect(routingPolicyItems[0]).toHaveTextContent(
useRoutingPolicesMockData.routingPoliciesData[0].name,
);
expect(routingPolicyItems[1]).toHaveTextContent(
useRoutingPolicesMockData.routingPoliciesData[1].name,
);
});
it('renders loading state', () => {
render(
<RoutingPoliciesList
routingPolicies={useRoutingPolicesMockData.routingPoliciesData}
isRoutingPoliciesLoading
isRoutingPoliciesError={false}
handlePolicyDetailsModalOpen={mockHandlePolicyDetailsModalOpen}
handleDeleteModalOpen={mockHandleDeleteModalOpen}
/>,
);
// Check for loading spinner by class name
expect(document.querySelector('.ant-spin-spinning')).toBeInTheDocument();
// Check that the table is in loading state (blurred)
expect(document.querySelector('.ant-spin-blur')).toBeInTheDocument();
});
it('renders error state', () => {
render(
<RoutingPoliciesList
routingPolicies={[]}
isRoutingPoliciesLoading={false}
isRoutingPoliciesError
handlePolicyDetailsModalOpen={mockHandlePolicyDetailsModalOpen}
handleDeleteModalOpen={mockHandleDeleteModalOpen}
/>,
);
expect(
screen.getByText('Something went wrong while fetching routing policies.'),
).toBeInTheDocument();
});
it('renders empty state', () => {
render(
<RoutingPoliciesList
routingPolicies={[]}
isRoutingPoliciesLoading={false}
isRoutingPoliciesError={false}
handlePolicyDetailsModalOpen={mockHandlePolicyDetailsModalOpen}
handleDeleteModalOpen={mockHandleDeleteModalOpen}
/>,
);
expect(screen.getByText('No routing policies found.')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,423 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import * as appHooks from 'providers/App/App';
import RoutingPolicyDetails from '../RoutingPolicyDetails';
import {
getAppContextMockState,
MOCK_CHANNEL_1,
MOCK_CHANNEL_2,
MOCK_ROUTING_POLICY_1,
} from './testUtils';
jest.spyOn(appHooks, 'useAppContext').mockReturnValue(getAppContextMockState());
const mockHandlePolicyDetailsModalAction = jest.fn();
const mockCloseModal = jest.fn();
const mockChannels = [MOCK_CHANNEL_1, MOCK_CHANNEL_2];
const mockRoutingPolicy = MOCK_ROUTING_POLICY_1;
const mockRefreshChannels = jest.fn();
const NEW_NAME = 'New Name';
const NEW_EXPRESSION = 'New Expression';
const NEW_DESCRIPTION = 'New Description';
const SAVE_BUTTON_TEXT = 'Save Routing Policy';
const NO_CHANNELS_FOUND_TEXT = 'No channels yet.';
describe('RoutingPolicyDetails', () => {
it('renders base create layout with header, 3 inputs and footer', () => {
render(
<RoutingPolicyDetails
routingPolicy={mockRoutingPolicy}
closeModal={mockCloseModal}
mode="create"
channels={mockChannels}
handlePolicyDetailsModalAction={mockHandlePolicyDetailsModalAction}
isPolicyDetailsModalActionLoading={false}
isErrorChannels={false}
isLoadingChannels={false}
refreshChannels={mockRefreshChannels}
/>,
);
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(screen.getByText('Create routing policy')).toBeInTheDocument();
expect(screen.getByText('Routing Policy Name')).toBeInTheDocument();
expect(screen.getByText('Expression')).toBeInTheDocument();
expect(screen.getByText('Notification Channels')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
expect(
screen.getByRole('button', { name: SAVE_BUTTON_TEXT }),
).toBeInTheDocument();
});
it('renders base edit layout with header, 3 inputs and footer', () => {
render(
<RoutingPolicyDetails
routingPolicy={mockRoutingPolicy}
closeModal={mockCloseModal}
mode="edit"
channels={mockChannels}
handlePolicyDetailsModalAction={mockHandlePolicyDetailsModalAction}
isPolicyDetailsModalActionLoading={false}
isErrorChannels={false}
isLoadingChannels={false}
refreshChannels={mockRefreshChannels}
/>,
);
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(screen.getByText('Edit routing policy')).toBeInTheDocument();
expect(screen.getByText('Routing Policy Name')).toBeInTheDocument();
expect(screen.getByText('Expression')).toBeInTheDocument();
expect(screen.getByText('Notification Channels')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
expect(
screen.getByRole('button', { name: SAVE_BUTTON_TEXT }),
).toBeInTheDocument();
});
it('prefills inputs with existing policy values in edit mode', () => {
render(
<RoutingPolicyDetails
routingPolicy={mockRoutingPolicy}
closeModal={mockCloseModal}
mode="edit"
channels={mockChannels}
handlePolicyDetailsModalAction={mockHandlePolicyDetailsModalAction}
isPolicyDetailsModalActionLoading={false}
isErrorChannels={false}
isLoadingChannels={false}
refreshChannels={mockRefreshChannels}
/>,
);
const nameInput = screen.getByDisplayValue(mockRoutingPolicy.name);
expect(nameInput).toBeInTheDocument();
const expressionTextarea = screen.getByDisplayValue(
mockRoutingPolicy.expression,
);
expect(expressionTextarea).toBeInTheDocument();
expect(screen.getByText(MOCK_CHANNEL_1.name)).toBeInTheDocument();
});
it('creating and saving the routing policy works correctly', async () => {
render(
<RoutingPolicyDetails
routingPolicy={mockRoutingPolicy}
closeModal={mockCloseModal}
mode="create"
channels={mockChannels}
handlePolicyDetailsModalAction={mockHandlePolicyDetailsModalAction}
isPolicyDetailsModalActionLoading={false}
isErrorChannels={false}
isLoadingChannels={false}
refreshChannels={mockRefreshChannels}
/>,
);
const nameInput = screen.getByPlaceholderText('e.g. Base routing policy...');
expect(nameInput).toBeInTheDocument();
const expressionTextarea = screen.getByPlaceholderText(
'e.g. service.name == "payment" && threshold.name == "critical"',
);
expect(expressionTextarea).toBeInTheDocument();
const descriptionTextarea = screen.getByPlaceholderText(
'e.g. This is a routing policy that...',
);
expect(descriptionTextarea).toBeInTheDocument();
fireEvent.change(nameInput, { target: { value: NEW_NAME } });
fireEvent.change(expressionTextarea, { target: { value: NEW_EXPRESSION } });
fireEvent.change(descriptionTextarea, { target: { value: NEW_DESCRIPTION } });
const channelSelect = screen.getByRole('combobox');
fireEvent.mouseDown(channelSelect);
const channelOptions = await screen.findAllByText('Channel 1');
fireEvent.click(channelOptions[1]);
// Wait for the form to be valid before submitting
await waitFor(() => {
expect(screen.getByDisplayValue(NEW_NAME)).toBeInTheDocument();
});
const saveButton = screen.getByRole('button', {
name: 'Save Routing Policy',
});
fireEvent.click(saveButton);
// Wait for the form submission to complete
await waitFor(() => {
expect(mockHandlePolicyDetailsModalAction).toHaveBeenCalled();
});
expect(mockHandlePolicyDetailsModalAction).toHaveBeenCalledWith('create', {
name: NEW_NAME,
expression: NEW_EXPRESSION,
description: NEW_DESCRIPTION,
channels: ['Channel 1'],
});
});
it('editing and saving the routing policy works correctly', async () => {
render(
<RoutingPolicyDetails
routingPolicy={mockRoutingPolicy}
closeModal={mockCloseModal}
mode="edit"
channels={mockChannels}
handlePolicyDetailsModalAction={mockHandlePolicyDetailsModalAction}
isPolicyDetailsModalActionLoading={false}
isErrorChannels={false}
isLoadingChannels={false}
refreshChannels={mockRefreshChannels}
/>,
);
const nameInput = screen.getByDisplayValue(mockRoutingPolicy.name);
expect(nameInput).toBeInTheDocument();
const expressionTextarea = screen.getByDisplayValue(
mockRoutingPolicy.expression,
);
expect(expressionTextarea).toBeInTheDocument();
const descriptionTextarea = screen.getByDisplayValue(
mockRoutingPolicy.description || 'description 1',
);
expect(descriptionTextarea).toBeInTheDocument();
fireEvent.change(nameInput, { target: { value: NEW_NAME } });
fireEvent.change(expressionTextarea, { target: { value: NEW_EXPRESSION } });
fireEvent.change(descriptionTextarea, { target: { value: NEW_DESCRIPTION } });
// Wait for the form to be valid before submitting
await waitFor(() => {
expect(screen.getByDisplayValue(NEW_NAME)).toBeInTheDocument();
});
const saveButton = screen.getByRole('button', {
name: SAVE_BUTTON_TEXT,
});
fireEvent.click(saveButton);
// Wait for the form submission to complete
await waitFor(() => {
expect(mockHandlePolicyDetailsModalAction).toHaveBeenCalled();
});
expect(mockHandlePolicyDetailsModalAction).toHaveBeenCalledWith('edit', {
name: NEW_NAME,
expression: NEW_EXPRESSION,
description: NEW_DESCRIPTION,
channels: ['Channel 1'],
});
});
it('should close modal when cancel button is clicked', () => {
render(
<RoutingPolicyDetails
routingPolicy={mockRoutingPolicy}
closeModal={mockCloseModal}
mode="edit"
channels={mockChannels}
handlePolicyDetailsModalAction={mockHandlePolicyDetailsModalAction}
isPolicyDetailsModalActionLoading={false}
isErrorChannels={false}
isLoadingChannels={false}
refreshChannels={mockRefreshChannels}
/>,
);
const cancelButton = screen.getByRole('button', { name: 'Cancel' });
fireEvent.click(cancelButton);
expect(mockCloseModal).toHaveBeenCalled();
});
it('buttons should be disabled when loading', () => {
render(
<RoutingPolicyDetails
routingPolicy={mockRoutingPolicy}
closeModal={mockCloseModal}
mode="edit"
channels={mockChannels}
handlePolicyDetailsModalAction={mockHandlePolicyDetailsModalAction}
isPolicyDetailsModalActionLoading
isErrorChannels={false}
isLoadingChannels={false}
refreshChannels={mockRefreshChannels}
/>,
);
const cancelButton = screen.getByRole('button', { name: 'Cancel' });
expect(cancelButton).toBeDisabled();
const saveButton = screen.getByRole('button', {
name: new RegExp(SAVE_BUTTON_TEXT, 'i'),
});
expect(saveButton).toBeDisabled();
});
it('submit should not be called when inputs are invalid', () => {
render(
<RoutingPolicyDetails
routingPolicy={mockRoutingPolicy}
closeModal={mockCloseModal}
mode="create"
channels={mockChannels}
handlePolicyDetailsModalAction={mockHandlePolicyDetailsModalAction}
isPolicyDetailsModalActionLoading={false}
isErrorChannels={false}
isLoadingChannels={false}
refreshChannels={mockRefreshChannels}
/>,
);
const saveButton = screen.getByRole('button', {
name: SAVE_BUTTON_TEXT,
});
fireEvent.click(saveButton);
expect(mockHandlePolicyDetailsModalAction).not.toHaveBeenCalled();
});
it('notification channels select should be disabled when channels are loading', () => {
render(
<RoutingPolicyDetails
routingPolicy={mockRoutingPolicy}
closeModal={mockCloseModal}
mode="create"
channels={[]}
handlePolicyDetailsModalAction={mockHandlePolicyDetailsModalAction}
isPolicyDetailsModalActionLoading={false}
isErrorChannels={false}
isLoadingChannels
refreshChannels={mockRefreshChannels}
/>,
);
const channelSelect = screen.getByRole('combobox');
expect(channelSelect).toBeDisabled();
});
it('should show error state when channels fail to load', () => {
render(
<RoutingPolicyDetails
routingPolicy={mockRoutingPolicy}
closeModal={mockCloseModal}
mode="create"
channels={[]}
handlePolicyDetailsModalAction={mockHandlePolicyDetailsModalAction}
isPolicyDetailsModalActionLoading={false}
isErrorChannels
isLoadingChannels={false}
refreshChannels={mockRefreshChannels}
/>,
);
const channelSelect = screen.getByRole('combobox');
const selectContainer = channelSelect.closest('.ant-select');
expect(selectContainer).toHaveClass('ant-select-status-error');
});
it('should show empty state when no channels are available', () => {
render(
<RoutingPolicyDetails
routingPolicy={mockRoutingPolicy}
closeModal={mockCloseModal}
mode="create"
channels={[]}
handlePolicyDetailsModalAction={mockHandlePolicyDetailsModalAction}
isPolicyDetailsModalActionLoading={false}
isErrorChannels={false}
isLoadingChannels={false}
refreshChannels={mockRefreshChannels}
/>,
);
const channelSelect = screen.getByRole('combobox');
fireEvent.mouseDown(channelSelect);
expect(screen.getByText(NO_CHANNELS_FOUND_TEXT)).toBeInTheDocument();
});
it('should show create channel button for admin users in empty state', () => {
render(
<RoutingPolicyDetails
routingPolicy={mockRoutingPolicy}
closeModal={mockCloseModal}
mode="create"
channels={[]}
handlePolicyDetailsModalAction={mockHandlePolicyDetailsModalAction}
isPolicyDetailsModalActionLoading={false}
isErrorChannels={false}
isLoadingChannels={false}
refreshChannels={mockRefreshChannels}
/>,
);
const channelSelect = screen.getByRole('combobox');
fireEvent.mouseDown(channelSelect);
expect(screen.getByText(NO_CHANNELS_FOUND_TEXT)).toBeInTheDocument();
expect(screen.getByText('Create one')).toBeInTheDocument();
});
it('should show admin message for non-admin users in empty state', () => {
jest
.spyOn(appHooks, 'useAppContext')
.mockReturnValue(getAppContextMockState({ role: 'VIEWER' }));
render(
<RoutingPolicyDetails
routingPolicy={mockRoutingPolicy}
closeModal={mockCloseModal}
mode="create"
channels={[]}
handlePolicyDetailsModalAction={mockHandlePolicyDetailsModalAction}
isPolicyDetailsModalActionLoading={false}
isErrorChannels={false}
isLoadingChannels={false}
refreshChannels={mockRefreshChannels}
/>,
);
const channelSelect = screen.getByRole('combobox');
fireEvent.mouseDown(channelSelect);
expect(screen.getByText(NO_CHANNELS_FOUND_TEXT)).toBeInTheDocument();
expect(
screen.getByText('Please ask your admin to create one.'),
).toBeInTheDocument();
expect(screen.queryByText('Create one')).not.toBeInTheDocument();
});
it('should call refreshChannels when refresh button is clicked in empty state', () => {
render(
<RoutingPolicyDetails
routingPolicy={mockRoutingPolicy}
closeModal={mockCloseModal}
mode="create"
channels={[]}
handlePolicyDetailsModalAction={mockHandlePolicyDetailsModalAction}
isPolicyDetailsModalActionLoading={false}
isErrorChannels={false}
isLoadingChannels={false}
refreshChannels={mockRefreshChannels}
/>,
);
const channelSelect = screen.getByRole('combobox');
fireEvent.mouseDown(channelSelect);
const refreshButton = screen.getByText('Refresh');
expect(refreshButton).toBeInTheDocument();
fireEvent.click(refreshButton);
expect(mockRefreshChannels).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,126 @@
import { fireEvent, render, screen } from '@testing-library/react';
import * as appHooks from 'providers/App/App';
import { ROLES, USER_ROLES } from 'types/roles';
import RoutingPolicyListItem from '../RoutingPolicyListItem';
import { getAppContextMockState, MOCK_ROUTING_POLICY_1 } from './testUtils';
const mockFormatTimezoneAdjustedTimestamp = jest.fn();
jest.mock('providers/Timezone', () => ({
useTimezone: (): any => ({
formatTimezoneAdjustedTimestamp: mockFormatTimezoneAdjustedTimestamp,
}),
}));
jest.spyOn(appHooks, 'useAppContext').mockReturnValue(getAppContextMockState());
const mockRoutingPolicy = MOCK_ROUTING_POLICY_1;
const mockHandlePolicyDetailsModalOpen = jest.fn();
const mockHandleDeleteModalOpen = jest.fn();
const EDIT_ROUTING_POLICY_TEST_ID = 'edit-routing-policy';
const DELETE_ROUTING_POLICY_TEST_ID = 'delete-routing-policy';
describe('RoutingPolicyListItem', () => {
it('should render properly in collapsed state', () => {
render(
<RoutingPolicyListItem
routingPolicy={mockRoutingPolicy}
handlePolicyDetailsModalOpen={mockHandlePolicyDetailsModalOpen}
handleDeleteModalOpen={mockHandleDeleteModalOpen}
/>,
);
expect(screen.getByText(mockRoutingPolicy.name)).toBeInTheDocument();
expect(screen.getByTestId(EDIT_ROUTING_POLICY_TEST_ID)).toBeInTheDocument();
expect(screen.getByTestId(DELETE_ROUTING_POLICY_TEST_ID)).toBeInTheDocument();
});
it('should render properly in expanded state', () => {
render(
<RoutingPolicyListItem
routingPolicy={mockRoutingPolicy}
handlePolicyDetailsModalOpen={mockHandlePolicyDetailsModalOpen}
handleDeleteModalOpen={mockHandleDeleteModalOpen}
/>,
);
expect(screen.getByText(mockRoutingPolicy.name)).toBeInTheDocument();
fireEvent.click(screen.getByText(mockRoutingPolicy.name));
expect(screen.getByText(mockRoutingPolicy.expression)).toBeInTheDocument();
expect(screen.getByText(mockRoutingPolicy.channels[0])).toBeInTheDocument();
expect(
screen.getByText(mockRoutingPolicy.createdBy || 'user1@signoz.io'),
).toBeInTheDocument();
expect(
screen.getByText(mockRoutingPolicy.description || 'description 1'),
).toBeInTheDocument();
});
it('should call handlePolicyDetailsModalOpen when edit button is clicked', () => {
render(
<RoutingPolicyListItem
routingPolicy={mockRoutingPolicy}
handlePolicyDetailsModalOpen={mockHandlePolicyDetailsModalOpen}
handleDeleteModalOpen={mockHandleDeleteModalOpen}
/>,
);
fireEvent.click(screen.getByTestId(EDIT_ROUTING_POLICY_TEST_ID));
expect(mockHandlePolicyDetailsModalOpen).toHaveBeenCalledWith(
'edit',
mockRoutingPolicy,
);
});
it('should call handleDeleteModalOpen when delete button is clicked', () => {
render(
<RoutingPolicyListItem
routingPolicy={mockRoutingPolicy}
handlePolicyDetailsModalOpen={mockHandlePolicyDetailsModalOpen}
handleDeleteModalOpen={mockHandleDeleteModalOpen}
/>,
);
fireEvent.click(screen.getByTestId(DELETE_ROUTING_POLICY_TEST_ID));
expect(mockHandleDeleteModalOpen).toHaveBeenCalledWith(mockRoutingPolicy);
});
it('edit and delete buttons should not be rendered for viewer role', () => {
jest
.spyOn(appHooks, 'useAppContext')
.mockReturnValue(
getAppContextMockState({ role: USER_ROLES.VIEWER as ROLES }),
);
render(
<RoutingPolicyListItem
routingPolicy={mockRoutingPolicy}
handlePolicyDetailsModalOpen={mockHandlePolicyDetailsModalOpen}
handleDeleteModalOpen={mockHandleDeleteModalOpen}
/>,
);
expect(
screen.queryByTestId(EDIT_ROUTING_POLICY_TEST_ID),
).not.toBeInTheDocument();
expect(
screen.queryByTestId(DELETE_ROUTING_POLICY_TEST_ID),
).not.toBeInTheDocument();
});
it('in details panel, show "-" for undefined values', () => {
render(
<RoutingPolicyListItem
routingPolicy={mockRoutingPolicy}
handlePolicyDetailsModalOpen={mockHandlePolicyDetailsModalOpen}
handleDeleteModalOpen={mockHandleDeleteModalOpen}
/>,
);
// Expand the details panel
fireEvent.click(screen.getByText(mockRoutingPolicy.name));
const updatedByRow = screen.getByText('Updated by').parentElement;
expect(updatedByRow).toHaveTextContent('-');
const updatedOnRow = screen.getByText('Updated on').parentElement;
expect(updatedOnRow).toHaveTextContent('-');
});
});

View File

@@ -0,0 +1,121 @@
import { IAppContext, IUser } from 'providers/App/types';
import { Channels } from 'types/api/channels/getAll';
import { RoutingPolicy, UseRoutingPoliciesReturn } from '../types';
export const MOCK_ROUTING_POLICY_1: RoutingPolicy = {
id: '1',
name: 'Routing Policy 1',
expression: 'expression 1',
description: 'description 1',
channels: ['Channel 1'],
createdAt: '2021-01-04',
updatedAt: undefined,
createdBy: 'user1@signoz.io',
updatedBy: undefined,
};
export const MOCK_ROUTING_POLICY_2: RoutingPolicy = {
id: '2',
name: 'Routing Policy 2',
expression: 'expression 2',
description: 'description 2',
channels: ['Channel 2'],
createdAt: '2021-01-05',
updatedAt: '2021-01-05',
createdBy: 'user2@signoz.io',
updatedBy: 'user2@signoz.io',
};
export const MOCK_CHANNEL_1: Channels = {
name: 'Channel 1',
created_at: '2021-01-01',
data: 'data 1',
id: '1',
type: 'type 1',
updated_at: '2021-01-01',
};
export const MOCK_CHANNEL_2: Channels = {
name: 'Channel 2',
created_at: '2021-01-02',
data: 'data 2',
id: '2',
type: 'type 2',
updated_at: '2021-01-02',
};
export function getUseRoutingPoliciesMockData(
overrides?: Partial<UseRoutingPoliciesReturn>,
): UseRoutingPoliciesReturn {
return {
selectedRoutingPolicy: MOCK_ROUTING_POLICY_1,
routingPoliciesData: [MOCK_ROUTING_POLICY_1, MOCK_ROUTING_POLICY_2],
isLoadingRoutingPolicies: false,
isErrorRoutingPolicies: false,
channels: [MOCK_CHANNEL_1, MOCK_CHANNEL_2],
isLoadingChannels: false,
searchTerm: '',
setSearchTerm: jest.fn(),
isDeleteModalOpen: false,
handleDeleteModalOpen: jest.fn(),
handleDeleteModalClose: jest.fn(),
handleDeleteRoutingPolicy: jest.fn(),
isDeletingRoutingPolicy: false,
policyDetailsModalState: {
mode: null,
isOpen: false,
},
handlePolicyDetailsModalClose: jest.fn(),
handlePolicyDetailsModalOpen: jest.fn(),
handlePolicyDetailsModalAction: jest.fn(),
isPolicyDetailsModalActionLoading: false,
isErrorChannels: false,
refreshChannels: jest.fn(),
...overrides,
};
}
export function getAppContextMockState(
overrides?: Partial<IUser>,
): IAppContext {
return {
user: {
accessJwt: 'some-token',
refreshJwt: 'some-refresh-token',
id: 'some-user-id',
email: 'user@signoz.io',
displayName: 'John Doe',
createdAt: 1732544623,
organization: 'Nightswatch',
orgId: 'does-not-matter-id',
role: 'ADMIN',
...overrides,
},
activeLicense: null,
trialInfo: null,
featureFlags: null,
orgPreferences: null,
userPreferences: null,
isLoggedIn: false,
org: null,
isFetchingUser: false,
isFetchingActiveLicense: false,
isFetchingFeatureFlags: false,
isFetchingOrgPreferences: false,
userFetchError: undefined,
activeLicenseFetchError: null,
featureFlagsFetchError: undefined,
orgPreferencesFetchError: undefined,
changelog: null,
showChangelogModal: false,
activeLicenseRefetch: jest.fn(),
updateUser: jest.fn(),
updateOrgPreferences: jest.fn(),
updateUserPreferenceInContext: jest.fn(),
updateOrg: jest.fn(),
updateChangelog: jest.fn(),
toggleChangelogModal: jest.fn(),
versionData: null,
hasEditPermission: false,
};
}

View File

@@ -0,0 +1,8 @@
import { RoutingPolicyDetailsFormState } from './types';
export const INITIAL_ROUTING_POLICY_DETAILS_FORM_STATE: RoutingPolicyDetailsFormState = {
name: '',
expression: '',
channels: [],
description: '',
};

View File

@@ -0,0 +1,3 @@
import RoutingPolicies from './RoutingPolicies';
export default RoutingPolicies;

View File

@@ -0,0 +1,452 @@
.routing-policies-container {
display: flex;
justify-content: center;
width: 100%;
margin-top: 8px;
.routing-policies-content {
width: calc(100% - 30px);
max-width: 736px;
.title {
color: var(--bg-vanilla-100);
font-size: var(--font-size-lg);
font-style: normal;
font-weight: var(--font-weight-normal);
line-height: 28px;
letter-spacing: -0.09px;
}
.subtitle {
color: var(--bg-vanilla-400);
font-size: var(--font-size-sm);
font-style: normal;
font-weight: var(--font-weight-normal);
line-height: 20px;
letter-spacing: -0.07px;
}
.ant-input-affix-wrapper {
margin-top: 16px;
margin-bottom: 8px;
}
.toolbar {
display: flex;
align-items: center;
gap: 8px;
.ant-btn {
margin-top: 8px;
}
}
.routing-policies-table {
.no-routing-policies-message-container {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 16px;
min-height: 200px;
.empty-state-svg {
width: 40px;
height: 40px;
}
.ant-typography {
color: var(--bg-vanilla-400);
}
}
}
}
}
.routing-policies-table {
.ant-table {
background: none !important;
}
.ant-table-cell {
padding: 0 !important;
border: 0 !important;
width: 736px;
}
.ant-table-tbody {
display: flex;
flex-direction: column;
gap: 16px;
.policy-list-item {
border: 1px solid var(--bg-slate-500);
background-color: var(--bg-ink-400);
border-radius: 6px;
.ant-collapse-header {
height: 56px;
align-items: center;
}
.policy-list-item-header {
.ant-typography {
color: var(--bg-vanilla-400);
font-family: Inter;
font-size: 14px;
font-weight: 400;
line-height: 18px;
}
.action-btn {
display: flex;
align-items: center;
gap: 20px;
cursor: pointer;
}
}
.policy-list-item-content {
.policy-list-item-content-row {
display: grid;
grid-template-columns: 128px 1fr;
margin-bottom: 13px;
.ant-typography:first-child {
color: var(--bg-vanilla-400);
font-family: Inter;
font-size: 14px;
font-weight: 400;
line-height: 20px;
}
.ant-typography:last-child,
div .ant-typography {
color: var(--bg-vanilla-100);
font-family: Inter;
font-size: 14px;
font-weight: 400;
line-height: 18px;
}
}
}
.ant-collapse-content-box {
padding: 12px 20px 12px 38px;
}
.ant-collapse-content-active {
border-top: none;
}
.ant-collapse-item {
border: none;
}
}
}
}
.create-policy-modal {
.ant-modal-content {
padding: 16px;
border-radius: 4px;
border: 1px solid var(--bg-slate-500);
background: var(--bg-ink-400);
box-shadow: 0px -4px 16px 2px rgba(0, 0, 0, 0.2);
.ant-modal-header {
background-color: var(--bg-ink-400);
.ant-typography {
margin: 0;
color: var(--bg-vanilla-100);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px;
}
}
.ant-modal-body {
.ant-divider {
margin: 16px 0;
border: 0.5px solid var(--bg-slate-500);
}
}
}
.create-policy-container {
display: flex;
flex-direction: column;
margin-bottom: 32px;
.input-group {
display: flex;
flex-direction: column;
gap: 4px;
.ant-typography {
color: var(--bg-vanilla-500);
font-size: 14px;
font-style: normal;
font-weight: 500;
line-height: 20px;
padding-bottom: 6px;
}
.ant-input {
width: 100%;
border-radius: 2px;
border: 1px solid var(--bg-slate-400);
background: var(--bg-ink-300);
}
.ant-select {
width: 100%;
.ant-select-selector {
border-radius: 2px;
border: 1px solid var(--bg-slate-400);
background: var(--bg-ink-300);
}
&.ant-select-focused .ant-select-selector {
border-color: var(--bg-slate-400) !important;
box-shadow: none !important;
}
&:hover .ant-select-selector {
border-color: var(--bg-slate-400) !important;
}
}
}
}
.create-policy-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 16px;
.ant-btn {
border-radius: 2px;
}
}
}
.delete-policy-modal {
width: calc(100% - 30px) !important; /* Adjust the 20px as needed */
max-width: 384px;
.ant-modal-content {
padding: 0;
border-radius: 4px;
border: 1px solid var(--bg-slate-500);
background: var(--bg-ink-400);
box-shadow: 0px -4px 16px 2px rgba(0, 0, 0, 0.2);
.ant-modal-header {
padding: 16px;
background: var(--bg-ink-400);
}
.ant-modal-body {
padding: 0px 16px 28px 16px;
.ant-typography {
color: var(--bg-vanilla-400);
font-size: var(--font-size-sm);
font-style: normal;
font-weight: var(--font-weight-normal);
line-height: 20px;
letter-spacing: -0.07px;
}
.save-view-input {
margin-top: 8px;
display: flex;
gap: 8px;
}
.ant-color-picker-trigger {
padding: 6px;
border-radius: 2px;
border: 1px solid var(--bg-slate-400);
background: var(--bg-ink-300);
width: 32px;
height: 32px;
.ant-color-picker-color-block {
border-radius: 50px;
width: 16px;
height: 16px;
flex-shrink: 0;
.ant-color-picker-color-block-inner {
display: flex;
justify-content: center;
align-items: center;
}
}
}
}
.ant-modal-footer {
display: flex;
justify-content: flex-end;
padding: 16px 16px;
margin: 0;
.cancel-btn {
display: flex;
align-items: center;
border: none;
border-radius: 2px;
background: var(--bg-slate-500);
}
.delete-btn {
display: flex;
align-items: center;
border: none;
border-radius: 2px;
background: var(--bg-cherry-500);
margin-left: 12px;
}
.delete-btn:hover {
color: var(--bg-vanilla-100);
background: var(--bg-cherry-600);
}
}
}
.title {
color: var(--bg-vanilla-100);
font-size: var(--font-size-sm);
font-style: normal;
font-weight: var(--font-weight-medium);
line-height: 20px; /* 142.857% */
}
}
.lightMode {
.routing-policies-container {
.routing-policies-content {
.title {
color: var(--bg-ink-500);
}
}
}
.routing-policies-table {
.ant-table-tbody {
.policy-list-item {
border: 1px solid var(--bg-vanilla-300);
background-color: var(--bg-vanilla-100);
.policy-list-item-header {
.ant-typography {
color: var(--bg-slate-400);
}
}
.policy-list-item-content {
.policy-list-item-content-row {
.ant-typography:first-child {
color: var(--bg-slate-400);
}
.ant-typography:last-child,
div .ant-typography {
color: var(--bg-slate-100);
}
}
}
}
}
}
.create-policy-modal {
.ant-modal-content {
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-100);
.ant-modal-header {
background-color: var(--bg-vanilla-100);
.ant-typography {
color: var(--bg-slate-100);
}
}
.ant-modal-body {
.ant-divider {
border: 0.5px solid var(--bg-vanilla-300);
}
}
}
.create-policy-container {
.input-group {
.ant-typography {
color: var(--bg-slate-100);
}
.ant-input {
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-100);
}
.ant-select {
.ant-select-selector {
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-100);
}
&.ant-select-focused .ant-select-selector {
border-color: var(--bg-vanilla-300) !important;
box-shadow: none !important;
}
&:hover .ant-select-selector {
border-color: var(--bg-vanilla-300) !important;
}
}
}
}
}
.delete-policy-modal {
.ant-modal-content {
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-100);
.ant-modal-header {
background: var(--bg-vanilla-100);
}
.ant-modal-body {
.ant-typography {
color: var(--bg-slate-400);
}
}
.ant-modal-footer {
.cancel-btn {
background: var(--bg-vanilla-300);
color: var(--bg-slate-100);
}
.delete-btn {
background: var(--bg-cherry-500);
color: var(--bg-vanilla-100);
}
.delete-btn:hover {
color: var(--bg-vanilla-100);
background: var(--bg-cherry-600);
}
}
}
.title {
color: var(--bg-slate-100);
}
}
}

View File

@@ -0,0 +1,115 @@
import { Channels } from 'types/api/channels/getAll';
export interface RoutingPolicy {
id: string;
name: string;
expression: string;
channels: string[];
description: string | undefined;
createdAt: string | undefined;
updatedAt: string | undefined;
createdBy: string | undefined;
updatedBy: string | undefined;
}
type HandlePolicyDetailsModalOpen = (
mode: PolicyDetailsModalMode,
routingPolicy: RoutingPolicy | null,
) => void;
type HandlePolicyDetailsModalAction = (
mode: PolicyDetailsModalMode,
routingPolicyData: {
name: string;
expression: string;
channels: string[];
description: string;
},
) => void;
type HandleDeleteModalOpen = (routingPolicy: RoutingPolicy) => void;
export type PolicyDetailsModalMode = 'create' | 'edit' | null;
export interface RoutingPolicyListProps {
routingPolicies: RoutingPolicy[];
isRoutingPoliciesLoading: boolean;
isRoutingPoliciesError: boolean;
handlePolicyDetailsModalOpen: HandlePolicyDetailsModalOpen;
handleDeleteModalOpen: HandleDeleteModalOpen;
}
export interface RoutingPolicyListItemProps {
routingPolicy: RoutingPolicy;
handlePolicyDetailsModalOpen: HandlePolicyDetailsModalOpen;
handleDeleteModalOpen: HandleDeleteModalOpen;
}
export interface PolicyListItemHeaderProps {
name: string;
handleEdit: () => void;
handleDelete: () => void;
}
export interface PolicyListItemContentProps {
routingPolicy: RoutingPolicy;
}
export interface RoutingPolicyDetailsProps {
routingPolicy: RoutingPolicy | null;
closeModal: () => void;
mode: PolicyDetailsModalMode;
channels: Channels[];
isErrorChannels: boolean;
isLoadingChannels: boolean;
handlePolicyDetailsModalAction: HandlePolicyDetailsModalAction;
isPolicyDetailsModalActionLoading: boolean;
refreshChannels: () => void;
}
export interface DeleteRoutingPolicyProps {
routingPolicy: RoutingPolicy | null;
isDeletingRoutingPolicy: boolean;
handleDelete: () => void;
handleClose: () => void;
}
export interface UseRoutingPoliciesReturn {
// Routing Policies
selectedRoutingPolicy: RoutingPolicy | null;
routingPoliciesData: RoutingPolicy[];
isLoadingRoutingPolicies: boolean;
isErrorRoutingPolicies: boolean;
// Channels
channels: Channels[];
isLoadingChannels: boolean;
isErrorChannels: boolean;
refreshChannels: () => void;
// Search
searchTerm: string;
setSearchTerm: (searchTerm: string) => void;
// Delete Modal
isDeleteModalOpen: boolean;
handleDeleteModalOpen: (routingPolicy: RoutingPolicy) => void;
handleDeleteModalClose: () => void;
handleDeleteRoutingPolicy: () => void;
isDeletingRoutingPolicy: boolean;
// Policy Details Modal
policyDetailsModalState: PolicyDetailsModalState;
handlePolicyDetailsModalClose: () => void;
handlePolicyDetailsModalOpen: HandlePolicyDetailsModalOpen;
handlePolicyDetailsModalAction: HandlePolicyDetailsModalAction;
isPolicyDetailsModalActionLoading: boolean;
}
export interface PolicyDetailsModalState {
mode: PolicyDetailsModalMode;
isOpen: boolean;
}
export interface RoutingPolicyDetailsFormState {
name: string;
expression: string;
channels: string[];
description: string;
}

View File

@@ -0,0 +1,240 @@
import './styles.scss';
import { toast } from '@signozhq/sonner';
import getAllChannels from 'api/channels/getAll';
import { GetRoutingPoliciesResponse } from 'api/routingPolicies/getRoutingPolicies';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useCreateRoutingPolicy } from 'hooks/routingPolicies/useCreateRoutingPolicy';
import { useDeleteRoutingPolicy } from 'hooks/routingPolicies/useDeleteRoutingPolicy';
import { useGetRoutingPolicies } from 'hooks/routingPolicies/useGetRoutingPolicies';
import { useUpdateRoutingPolicy } from 'hooks/routingPolicies/useUpdateRoutingPolicy';
import { useMemo, useState } from 'react';
import { useQuery, useQueryClient } from 'react-query';
import { SuccessResponseV2 } from 'types/api';
import { Channels } from 'types/api/channels/getAll';
import APIError from 'types/api/error';
import {
PolicyDetailsModalMode,
PolicyDetailsModalState,
RoutingPolicy,
UseRoutingPoliciesReturn,
} from './types';
import {
mapApiResponseToRoutingPolicies,
mapRoutingPolicyToCreateApiPayload,
mapRoutingPolicyToUpdateApiPayload,
} from './utils';
function useRoutingPolicies(): UseRoutingPoliciesReturn {
const queryClient = useQueryClient();
// Local state
const [searchTerm, setSearchTerm] = useState('');
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [
policyDetailsModalState,
setPolicyDetailsModalState,
] = useState<PolicyDetailsModalState>({
mode: null,
isOpen: false,
});
const [
selectedRoutingPolicy,
setSelectedRoutingPolicy,
] = useState<RoutingPolicy | null>(null);
// Routing Policies list
const {
data: routingPolicies,
isLoading: isLoadingRoutingPolicies,
isError: isErrorRoutingPolicies,
} = useGetRoutingPolicies();
const routingPoliciesData = useMemo(() => {
const unfilteredRoutingPolicies = mapApiResponseToRoutingPolicies(
routingPolicies as SuccessResponseV2<GetRoutingPoliciesResponse>,
);
return unfilteredRoutingPolicies.filter((routingPolicy) =>
routingPolicy.name.toLowerCase().includes(searchTerm.toLowerCase()),
);
}, [routingPolicies, searchTerm]);
// Channels list
const {
data,
isLoading: isLoadingChannels,
isError: isErrorChannels,
refetch: refetchChannels,
} = useQuery<SuccessResponseV2<Channels[]>, APIError>(['getChannels'], {
queryFn: () => getAllChannels(),
});
const channels = data?.data || [];
const refreshChannels = (): void => {
refetchChannels();
};
// Handlers
const handlePolicyDetailsModalOpen = (
mode: PolicyDetailsModalMode,
routingPolicy: RoutingPolicy | null,
): void => {
if (routingPolicy) {
setSelectedRoutingPolicy(routingPolicy);
}
setPolicyDetailsModalState({
isOpen: true,
mode,
});
};
const handlePolicyDetailsModalClose = (): void => {
setSelectedRoutingPolicy(null);
setPolicyDetailsModalState({
isOpen: false,
mode: null,
});
};
const handleDeleteModalOpen = (routingPolicy: RoutingPolicy): void => {
setSelectedRoutingPolicy(routingPolicy);
setIsDeleteModalOpen(true);
};
const handleDeleteModalClose = (): void => {
setSelectedRoutingPolicy(null);
setIsDeleteModalOpen(false);
};
// Create Routing Policy
const {
mutate: createRoutingPolicy,
isLoading: isCreating,
} = useCreateRoutingPolicy();
// Update Routing Policy
const {
mutate: updateRoutingPolicy,
isLoading: isUpdating,
} = useUpdateRoutingPolicy();
// Policy Details Modal Action (Create or Update)
const handlePolicyDetailsModalAction = (
mode: PolicyDetailsModalMode,
routingPolicyData: {
name: string;
expression: string;
channels: string[];
description: string;
},
): void => {
if (mode === 'create') {
createRoutingPolicy(
{
payload: mapRoutingPolicyToCreateApiPayload(
routingPolicyData.name,
routingPolicyData.expression,
routingPolicyData.channels,
routingPolicyData.description,
),
},
{
onSuccess: () => {
toast.success('Routing policy created successfully');
queryClient.invalidateQueries(REACT_QUERY_KEY.GET_ROUTING_POLICIES);
handlePolicyDetailsModalClose();
},
onError: (error) => {
toast.error(`Error: ${error.message}`);
},
},
);
} else if (mode === 'edit' && selectedRoutingPolicy) {
updateRoutingPolicy(
{
id: selectedRoutingPolicy.id,
payload: mapRoutingPolicyToUpdateApiPayload(
routingPolicyData.name,
routingPolicyData.expression,
routingPolicyData.channels,
routingPolicyData.description,
),
},
{
onSuccess: () => {
toast.success('Routing policy updated successfully');
queryClient.invalidateQueries(REACT_QUERY_KEY.GET_ROUTING_POLICIES);
handlePolicyDetailsModalClose();
},
onError: () => {
toast.error('Failed to update routing policy');
},
},
);
}
};
// Policy Details Modal Action Loading (Creating or Updating)
const isPolicyDetailsModalActionLoading = useMemo(() => {
if (policyDetailsModalState.mode === 'create') {
return isCreating;
}
if (policyDetailsModalState.mode === 'edit') {
return isUpdating;
}
return false;
}, [policyDetailsModalState.mode, isCreating, isUpdating]);
// Delete Routing Policy
const {
mutate: deleteRoutingPolicy,
isLoading: isDeletingRoutingPolicy,
} = useDeleteRoutingPolicy();
const handleDeleteRoutingPolicy = (): void => {
if (!selectedRoutingPolicy) {
return;
}
deleteRoutingPolicy(selectedRoutingPolicy.id, {
onSuccess: () => {
toast.success('Routing policy deleted successfully');
queryClient.invalidateQueries(REACT_QUERY_KEY.GET_ROUTING_POLICIES);
handleDeleteModalClose();
},
onError: () => {
toast.error('Failed to delete routing policy');
},
});
};
return {
// Routing Policies
selectedRoutingPolicy,
routingPoliciesData,
isLoadingRoutingPolicies,
isErrorRoutingPolicies,
// Channels
channels,
isLoadingChannels,
isErrorChannels,
refreshChannels,
// Search
searchTerm,
setSearchTerm,
// Delete Modal
isDeleteModalOpen,
handleDeleteModalOpen,
handleDeleteModalClose,
handleDeleteRoutingPolicy,
isDeletingRoutingPolicy,
// Policy Details Modal
policyDetailsModalState,
isPolicyDetailsModalActionLoading,
handlePolicyDetailsModalAction,
handlePolicyDetailsModalOpen,
handlePolicyDetailsModalClose,
};
}
export default useRoutingPolicies;

View File

@@ -0,0 +1,61 @@
import { CreateRoutingPolicyBody } from 'api/routingPolicies/createRoutingPolicy';
import { GetRoutingPoliciesResponse } from 'api/routingPolicies/getRoutingPolicies';
import { UpdateRoutingPolicyBody } from 'api/routingPolicies/updateRoutingPolicy';
import { SuccessResponseV2 } from 'types/api';
import { RoutingPolicy } from './types';
export function showRoutingPoliciesPage(): boolean {
return localStorage.getItem('showRoutingPoliciesPage') === 'true';
}
export function mapApiResponseToRoutingPolicies(
response: SuccessResponseV2<GetRoutingPoliciesResponse>,
): RoutingPolicy[] {
return (
response?.data?.data?.map((policyData) => ({
id: policyData.id,
name: policyData.name,
expression: policyData.expression,
description: policyData.description,
channels: policyData.channels,
createdAt: policyData.createdAt,
updatedAt: policyData.updatedAt,
createdBy: policyData.createdBy,
updatedBy: policyData.updatedBy,
})) || []
);
}
export function mapRoutingPolicyToCreateApiPayload(
name: string,
expression: string,
channels: string[],
description: string,
): CreateRoutingPolicyBody {
return {
name,
expression,
actions: {
channels,
},
description,
};
}
// eslint-disable-next-line sonarjs/no-identical-functions
export function mapRoutingPolicyToUpdateApiPayload(
name: string,
expression: string,
channels: string[],
description: string,
): UpdateRoutingPolicyBody {
return {
name,
expression,
actions: {
channels,
},
description,
};
}

View File

@@ -66,13 +66,7 @@ export const useGetExplorerQueryRange = (
ENTITY_VERSION_V5,
{
...options,
queryKey: [
key,
selectedTimeInterval ?? globalSelectedInterval,
requestData,
minTime,
maxTime,
],
queryKey: [key, globalSelectedInterval, requestData, minTime, maxTime],
enabled: isEnabled,
},
headers,

View File

@@ -0,0 +1,24 @@
import createRoutingPolicy, {
CreateRoutingPolicyBody,
CreateRoutingPolicyResponse,
} from 'api/routingPolicies/createRoutingPolicy';
import { useMutation, UseMutationResult } from 'react-query';
import { ErrorResponseV2, SuccessResponseV2 } from 'types/api';
interface UseCreateRoutingPolicyProps {
payload: CreateRoutingPolicyBody;
}
export function useCreateRoutingPolicy(): UseMutationResult<
SuccessResponseV2<CreateRoutingPolicyResponse> | ErrorResponseV2,
Error,
UseCreateRoutingPolicyProps
> {
return useMutation<
SuccessResponseV2<CreateRoutingPolicyResponse> | ErrorResponseV2,
Error,
UseCreateRoutingPolicyProps
>({
mutationFn: ({ payload }) => createRoutingPolicy(payload),
});
}

View File

@@ -0,0 +1,19 @@
import deleteRoutingPolicy, {
DeleteRoutingPolicyResponse,
} from 'api/routingPolicies/deleteRoutingPolicy';
import { useMutation, UseMutationResult } from 'react-query';
import { ErrorResponseV2, SuccessResponseV2 } from 'types/api';
export function useDeleteRoutingPolicy(): UseMutationResult<
SuccessResponseV2<DeleteRoutingPolicyResponse> | ErrorResponseV2,
Error,
string
> {
return useMutation<
SuccessResponseV2<DeleteRoutingPolicyResponse> | ErrorResponseV2,
Error,
string
>({
mutationFn: (policyId) => deleteRoutingPolicy(policyId),
});
}

View File

@@ -0,0 +1,39 @@
import {
getRoutingPolicies,
GetRoutingPoliciesResponse,
} from 'api/routingPolicies/getRoutingPolicies';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useMemo } from 'react';
import { useQuery, UseQueryOptions, UseQueryResult } from 'react-query';
import { ErrorResponseV2, SuccessResponseV2 } from 'types/api';
type UseGetRoutingPolicies = (
options?: UseQueryOptions<
SuccessResponseV2<GetRoutingPoliciesResponse> | ErrorResponseV2,
Error
>,
headers?: Record<string, string>,
) => UseQueryResult<
SuccessResponseV2<GetRoutingPoliciesResponse> | ErrorResponseV2,
Error
>;
export const useGetRoutingPolicies: UseGetRoutingPolicies = (
options,
headers,
) => {
const queryKey = useMemo(
() => options?.queryKey || [REACT_QUERY_KEY.GET_ROUTING_POLICIES],
[options?.queryKey],
);
return useQuery<
SuccessResponseV2<GetRoutingPoliciesResponse> | ErrorResponseV2,
Error
>({
queryFn: ({ signal }) => getRoutingPolicies(signal, headers),
...options,
queryKey,
});
};

View File

@@ -0,0 +1,25 @@
import updateRoutingPolicy, {
UpdateRoutingPolicyBody,
UpdateRoutingPolicyResponse,
} from 'api/routingPolicies/updateRoutingPolicy';
import { useMutation, UseMutationResult } from 'react-query';
import { ErrorResponseV2, SuccessResponseV2 } from 'types/api';
interface UseUpdateRoutingPolicyProps {
id: string;
payload: UpdateRoutingPolicyBody;
}
export function useUpdateRoutingPolicy(): UseMutationResult<
SuccessResponseV2<UpdateRoutingPolicyResponse> | ErrorResponseV2,
Error,
UseUpdateRoutingPolicyProps
> {
return useMutation<
SuccessResponseV2<UpdateRoutingPolicyResponse> | ErrorResponseV2,
Error,
UseUpdateRoutingPolicyProps
>({
mutationFn: ({ id, payload }) => updateRoutingPolicy(id, payload),
});
}

View File

@@ -2,4 +2,14 @@
.ant-tabs-nav {
padding: 0 8px;
}
.configuration-tabs {
margin-top: -16px;
.ant-tabs-nav {
.ant-tabs-nav-wrap {
padding: 0 8px;
}
}
}
}

View File

@@ -7,11 +7,14 @@ import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection
import ROUTES from 'constants/routes';
import AllAlertRules from 'container/ListAlertRules';
import { PlannedDowntime } from 'container/PlannedDowntime/PlannedDowntime';
import RoutingPolicies from 'container/RoutingPolicies';
import { showRoutingPoliciesPage } from 'container/RoutingPolicies/utils';
import TriggeredAlerts from 'container/TriggeredAlerts';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { GalleryVerticalEnd, Pyramid } from 'lucide-react';
import AlertDetails from 'pages/AlertDetails';
import { useMemo } from 'react';
import { useLocation } from 'react-router-dom';
function AllAlertList(): JSX.Element {
@@ -25,6 +28,37 @@ function AllAlertList(): JSX.Element {
const search = urlQuery.get('search');
const showRoutingPoliciesPageFlag = showRoutingPoliciesPage();
const configurationTab = useMemo(() => {
if (showRoutingPoliciesPageFlag) {
const tabs = [
{
label: 'Planned Downtime',
key: 'planned-downtime',
children: <PlannedDowntime />,
},
{
label: 'Routing Policies',
key: 'routing-policies',
children: <RoutingPolicies />,
},
];
return (
<Tabs
className="configuration-tabs"
defaultActiveKey="planned-downtime"
items={tabs}
/>
);
}
return (
<div className="planned-downtime-container">
<PlannedDowntime />
</div>
);
}, [showRoutingPoliciesPageFlag]);
const items: TabsProps['items'] = [
{
label: (
@@ -58,11 +92,7 @@ function AllAlertList(): JSX.Element {
</div>
),
key: 'Configuration',
children: (
<div className="planned-downtime-container">
<PlannedDowntime />
</div>
),
children: configurationTab,
},
];

View File

@@ -9,18 +9,39 @@
color: var(--bg-vanilla-100);
.error-icon {
margin-bottom: 16px;
}
.title,
.actions {
.error-boundary-fallback-content {
display: flex;
align-items: center;
flex-direction: column;
max-width: 520px;
gap: 8px;
}
.actions {
margin-top: 16px;
.title,
.actions {
display: flex;
align-items: center;
gap: 8px;
}
.title {
color: var(--bg-vanilla-100);
font-size: 14px;
font-style: normal;
font-weight: 500;
line-height: 18px; /* 128.571% */
letter-spacing: -0.07px;
}
.description {
color: var(--bg-vanilla-400);
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 18px;
letter-spacing: -0.07px;
}
.actions {
margin-top: 16px;
}
}
}

View File

@@ -1,55 +1,56 @@
import './ErrorBoundaryFallback.styles.scss';
import { BugOutlined } from '@ant-design/icons';
import { Button, Typography } from 'antd';
import { Button } from 'antd';
import ROUTES from 'constants/routes';
import Slack from 'container/SideNav/Slack';
import { Home, TriangleAlert } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { Home, LifeBuoy } from 'lucide-react';
import { handleContactSupport } from 'pages/Integrations/utils';
import { useCallback } from 'react';
function ErrorBoundaryFallback(): JSX.Element {
const { t } = useTranslation(['errorDetails']);
const onClickSlackHandler = (): void => {
window.open('https://signoz.io/slack', '_blank');
};
const handleReload = (): void => {
// Go to home page
window.location.href = ROUTES.HOME;
};
const { isCloudUser: isCloudUserVal } = useGetTenantLicense();
const handleSupport = useCallback(() => {
handleContactSupport(isCloudUserVal);
}, [isCloudUserVal]);
return (
<div className="error-boundary-fallback-container">
<div className="error-icon">
<TriangleAlert size={48} />
</div>
<div className="title">
<BugOutlined />
<Typography.Title type="danger" level={4} style={{ margin: 0 }}>
{t('something_went_wrong')}
</Typography.Title>
</div>
<div className="error-boundary-fallback-content">
<div className="error-icon">
<img src="/Images/cloud.svg" alt="error-cloud-icon" />
</div>
<div className="title">Something went wrong :/</div>
<p>{t('contact_if_issue_exists')}</p>
<div className="description">
Our team is getting on top to resolve this. Please reach out to support if
the issue persists.
</div>
<div className="actions">
<Button
type="primary"
onClick={handleReload}
icon={<Home size={16} />}
className="periscope-btn primary"
>
Go Home
</Button>
<div className="actions">
<Button
type="primary"
onClick={handleReload}
icon={<Home size={16} />}
className="periscope-btn primary"
>
Go to Home
</Button>
<Button
className="periscope-btn secondary"
type="default"
onClick={onClickSlackHandler}
icon={<Slack />}
>
Slack Support
</Button>
<Button
className="periscope-btn secondary"
type="default"
onClick={handleSupport}
icon={<LifeBuoy size={16} />}
>
Contact Support
</Button>
</div>
</div>
</div>
);

View File

@@ -1,10 +1,49 @@
.support-page-container {
color: white;
padding-left: 48px;
padding-right: 48px;
max-height: 100vh;
overflow: hidden;
max-width: 1400px;
margin: 64px auto;
.support-page-header {
border-bottom: 1px solid var(--bg-slate-500);
background: rgba(11, 12, 14, 0.7);
backdrop-filter: blur(20px);
.support-page-header-title {
color: var(--bg-vanilla-100);
text-align: center;
font-family: Inter;
font-size: 13px;
font-style: normal;
line-height: 14px;
letter-spacing: 0.4px;
display: flex;
align-items: center;
gap: 8px;
padding: 16px;
}
}
.support-page-content {
padding: 16px;
.support-page-content-description {
color: var(--bg-vanilla-100);
text-align: left;
font-family: Inter;
font-size: 16px;
font-style: normal;
line-height: 24px;
letter-spacing: 0.4px;
display: flex;
align-items: center;
gap: 8px;
}
.support-channels {
margin: 24px 0;
}
}
}
.support-channels {
@@ -21,6 +60,16 @@
position: relative;
border: none !important;
border-radius: 4px;
border: 1px solid var(--bg-slate-400);
background: linear-gradient(
139deg,
rgba(18, 19, 23, 0.8) 0%,
rgba(18, 19, 23, 0.9) 98.68%
);
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(20px);
.support-channel-title {
width: 100%;
display: flex;
@@ -37,6 +86,21 @@
button {
max-width: 100%;
padding: 4px 16px;
.ant-typography {
font-size: 11px;
font-weight: 400;
line-height: 24px;
letter-spacing: -0.07px;
}
}
.support-channel-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
}
}
@@ -47,8 +111,50 @@
}
}
@media screen and (min-width: 1440px) {
.lightMode {
.support-page-container {
width: 80%;
.support-page-header {
border-bottom: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-100);
.support-page-header-title {
color: var(--bg-ink-400);
}
}
}
.support-page-content {
.support-page-content-description {
color: var(--bg-ink-400);
}
.support-channels {
.support-channel {
border: 1px solid var(--bg-vanilla-300);
background: linear-gradient(
139deg,
rgba(255, 255, 255, 0.8) 0%,
rgba(255, 255, 255, 0.9) 98.68%
);
box-shadow: 4px 10px 16px 2px rgba(255, 255, 255, 0.2);
backdrop-filter: blur(20px);
}
.support-channel-title {
color: var(--bg-ink-400);
}
.support-channel-action {
button {
.ant-typography {
color: var(--bg-ink-400);
}
}
.support-channel-btn {
color: var(--bg-ink-400);
}
}
}
}
}

View File

@@ -6,9 +6,11 @@ import updateCreditCardApi from 'api/v1/checkout/create';
import { FeatureKeys } from 'constants/features';
import { useNotifications } from 'hooks/useNotifications';
import {
ArrowUpRight,
Book,
CreditCard,
Github,
LifeBuoy,
MessageSquare,
Slack,
X,
@@ -45,34 +47,38 @@ const supportChannels = [
{
key: 'documentation',
name: 'Documentation',
icon: <Book />,
icon: <Book size={16} />,
title: 'Find answers in the documentation.',
url: 'https://signoz.io/docs/',
btnText: 'Visit docs',
isExternal: true,
},
{
key: 'github',
name: 'Github',
icon: <Github />,
icon: <Github size={16} />,
title: 'Create an issue on GitHub to report bugs or request new features.',
url: 'https://github.com/SigNoz/signoz/issues',
btnText: 'Create issue',
isExternal: true,
},
{
key: 'slack_community',
name: 'Slack Community',
icon: <Slack />,
icon: <Slack size={16} />,
title: 'Get support from the SigNoz community on Slack.',
url: 'https://signoz.io/slack',
btnText: 'Join Slack',
isExternal: true,
},
{
key: 'chat',
name: 'Chat',
icon: <MessageSquare />,
icon: <MessageSquare size={16} />,
title: 'Get quick support directly from the team.',
url: '',
btnText: 'Launch chat',
isExternal: false,
},
];
@@ -182,38 +188,45 @@ export default function Support(): JSX.Element {
return (
<div className="support-page-container">
<div className="support-page-header">
<Title level={3}> Help & Support </Title>
<Text style={{ fontSize: 14 }}>
<header className="support-page-header">
<div className="support-page-header-title" data-testid="support-page-title">
<LifeBuoy size={16} />
Support
</div>
</header>
<div className="support-page-content">
<div className="support-page-content-description">
We are here to help in case of questions or issues. Pick the channel that
is most convenient for you.
</Text>
</div>
</div>
<div className="support-channels">
{supportChannels.map(
(channel): JSX.Element => (
<Card className="support-channel" key={channel.key}>
<div className="support-channel-content">
<Title ellipsis level={5} className="support-channel-title">
{channel.icon}
{channel.name}{' '}
</Title>
<Text> {channel.title} </Text>
</div>
<div className="support-channels">
{supportChannels.map(
(channel): JSX.Element => (
<Card className="support-channel" key={channel.key}>
<div className="support-channel-content">
<Title ellipsis level={5} className="support-channel-title">
{channel.icon}
{channel.name}{' '}
</Title>
<Text> {channel.title} </Text>
</div>
<div className="support-channel-action">
<Button
type="default"
className="periscope-btn secondary"
onClick={(): void => handleChannelClick(channel)}
>
<Text ellipsis>{channel.btnText} </Text>
</Button>
</div>
</Card>
),
)}
<div className="support-channel-action">
<Button
className="periscope-btn secondary support-channel-btn"
type="default"
onClick={(): void => handleChannelClick(channel)}
>
<Text ellipsis>{channel.btnText} </Text>
{channel.isExternal && <ArrowUpRight size={14} />}
</Button>
</div>
</Card>
),
)}
</div>
</div>
{/* Add Credit Card Modal */}

103
go.mod
View File

@@ -19,7 +19,7 @@ require (
github.com/go-openapi/strfmt v0.23.0
github.com/go-redis/redis/v8 v8.11.5
github.com/go-redis/redismock/v8 v8.11.5
github.com/go-viper/mapstructure/v2 v2.3.0
github.com/go-viper/mapstructure/v2 v2.4.0
github.com/gojek/heimdall/v7 v7.0.3
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/google/uuid v1.6.0
@@ -27,7 +27,7 @@ require (
github.com/gorilla/mux v1.8.1
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674
github.com/huandu/go-sqlbuilder v1.35.0
github.com/jackc/pgx/v5 v5.7.5
github.com/jackc/pgx/v5 v5.7.6
github.com/json-iterator/go v1.1.12
github.com/knadh/koanf v1.5.0
github.com/knadh/koanf/v2 v2.2.0
@@ -35,14 +35,14 @@ require (
github.com/mattn/go-sqlite3 v1.14.24
github.com/open-telemetry/opamp-go v0.19.0
github.com/open-telemetry/opentelemetry-collector-contrib/pkg/stanza v0.128.0
github.com/openfga/api/proto v0.0.0-20250127102726-f9709139a369
github.com/openfga/api/proto v0.0.0-20250909172242-b4b2a12f5c67
github.com/openfga/language/pkg/go v0.2.0-beta.2.0.20250428093642-7aeebe78bbfe
github.com/opentracing/opentracing-go v1.2.0
github.com/patrickmn/go-cache v2.1.0+incompatible
github.com/pkg/errors v0.9.1
github.com/prometheus/alertmanager v0.28.1
github.com/prometheus/client_golang v1.23.0
github.com/prometheus/common v0.65.0
github.com/prometheus/client_golang v1.23.2
github.com/prometheus/common v0.66.1
github.com/prometheus/prometheus v0.304.1
github.com/rs/cors v1.11.1
github.com/russellhaering/gosaml2 v0.9.0
@@ -52,9 +52,9 @@ require (
github.com/sethvargo/go-password v0.2.0
github.com/smartystreets/goconvey v1.8.1
github.com/soheilhy/cmux v0.1.5
github.com/spf13/cobra v1.9.1
github.com/spf13/cobra v1.10.1
github.com/srikanthccv/ClickHouse-go-mock v0.12.0
github.com/stretchr/testify v1.10.0
github.com/stretchr/testify v1.11.1
github.com/tidwall/gjson v1.18.0
github.com/uptrace/bun v1.2.9
github.com/uptrace/bun/dialect/pgdialect v1.2.9
@@ -63,37 +63,48 @@ require (
go.opentelemetry.io/collector/otelcol v0.128.0
go.opentelemetry.io/collector/pdata v1.34.0
go.opentelemetry.io/contrib/config v0.10.0
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0
go.opentelemetry.io/otel v1.37.0
go.opentelemetry.io/otel/metric v1.37.0
go.opentelemetry.io/otel/sdk v1.37.0
go.opentelemetry.io/otel/trace v1.37.0
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0
go.opentelemetry.io/otel v1.38.0
go.opentelemetry.io/otel/metric v1.38.0
go.opentelemetry.io/otel/sdk v1.38.0
go.opentelemetry.io/otel/trace v1.38.0
go.uber.org/multierr v1.11.0
go.uber.org/zap v1.27.0
golang.org/x/crypto v0.40.0
golang.org/x/crypto v0.41.0
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b
golang.org/x/net v0.42.0
golang.org/x/net v0.43.0
golang.org/x/oauth2 v0.30.0
golang.org/x/sync v0.16.0
golang.org/x/text v0.27.0
google.golang.org/protobuf v1.36.6
golang.org/x/sync v0.17.0
golang.org/x/text v0.28.0
google.golang.org/protobuf v1.36.9
gopkg.in/yaml.v2 v2.4.0
gopkg.in/yaml.v3 v3.0.1
k8s.io/apimachinery v0.33.0
k8s.io/apimachinery v0.34.0
)
require (
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
modernc.org/libc v1.66.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.39.0 // indirect
)
require (
cel.dev/expr v0.24.0 // indirect
cloud.google.com/go/auth v0.16.1 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.7.0 // indirect
cloud.google.com/go/compute/metadata v0.8.2 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 // indirect
github.com/ClickHouse/ch-go v0.67.0 // indirect
github.com/Masterminds/squirrel v1.5.4 // indirect
github.com/Yiling-J/theine-go v0.6.1 // indirect
github.com/Yiling-J/theine-go v0.6.2 // indirect
github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/armon/go-metrics v0.4.1 // indirect
@@ -104,7 +115,7 @@ require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cenkalti/backoff/v5 v5.0.2 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/coder/quartz v0.1.2 // indirect
github.com/coreos/go-systemd/v22 v22.5.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
@@ -122,7 +133,7 @@ require (
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-faster/city v1.0.1 // indirect
github.com/go-faster/errors v0.7.1 // indirect
github.com/go-jose/go-jose/v4 v4.0.5 // indirect
github.com/go-jose/go-jose/v4 v4.1.1 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
@@ -142,7 +153,7 @@ require (
github.com/golang/protobuf v1.5.4 // indirect
github.com/golang/snappy v1.0.0 // indirect
github.com/google/btree v1.1.3 // indirect
github.com/google/cel-go v0.26.0 // indirect
github.com/google/cel-go v0.26.1 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
@@ -151,7 +162,7 @@ require (
github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc // indirect
github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 // indirect
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
github.com/hashicorp/go-msgpack/v2 v2.1.1 // indirect
@@ -192,7 +203,7 @@ require (
github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f // indirect
github.com/natefinch/wrap v0.2.0 // indirect
@@ -203,14 +214,14 @@ require (
github.com/open-telemetry/opentelemetry-collector-contrib/internal/exp/metrics v0.128.0 // indirect
github.com/open-telemetry/opentelemetry-collector-contrib/pkg/pdatautil v0.128.0 // indirect
github.com/open-telemetry/opentelemetry-collector-contrib/processor/deltatocumulativeprocessor v0.128.0 // indirect
github.com/openfga/openfga v1.9.5
github.com/openfga/openfga v1.10.1
github.com/paulmach/orb v0.11.1 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pierrec/lz4/v4 v4.1.22 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/pressly/goose/v3 v3.24.3 // indirect
github.com/pressly/goose/v3 v3.25.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/exporter-toolkit v0.14.0 // indirect
github.com/prometheus/otlptranslator v0.0.0-20250320144820-d800c8b0eb07 // indirect
@@ -218,7 +229,7 @@ require (
github.com/prometheus/sigv4 v0.1.2 // indirect
github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/sagikazarmark/locafero v0.7.0 // indirect
github.com/sagikazarmark/locafero v0.9.0 // indirect
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 // indirect
github.com/segmentio/asm v1.2.0 // indirect
github.com/segmentio/backo-go v1.0.1 // indirect
@@ -229,9 +240,9 @@ require (
github.com/shurcooL/vfsgen v0.0.0-20230704071429-0000e147ea92 // indirect
github.com/smarty/assertions v1.15.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.12.0 // indirect
github.com/spf13/cast v1.9.2 // indirect
github.com/spf13/pflag v1.0.7 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/spf13/viper v1.20.1 // indirect
github.com/stoewer/go-strcase v1.3.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
@@ -297,8 +308,8 @@ require (
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.12.2 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.36.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.36.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.36.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.36.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0 // indirect
go.opentelemetry.io/otel/exporters/prometheus v0.58.0
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.12.2 // indirect
@@ -306,24 +317,24 @@ require (
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.36.0 // indirect
go.opentelemetry.io/otel/log v0.12.2 // indirect
go.opentelemetry.io/otel/sdk/log v0.12.2 // indirect
go.opentelemetry.io/otel/sdk/metric v1.36.0
go.opentelemetry.io/proto/otlp v1.6.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.38.0
go.opentelemetry.io/proto/otlp v1.8.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/goleak v1.3.0 // indirect
go.uber.org/mock v0.5.2 // indirect
go.yaml.in/yaml/v3 v3.0.3 // indirect
golang.org/x/mod v0.25.0 // indirect
golang.org/x/sys v0.34.0 // indirect
go.uber.org/mock v0.6.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/mod v0.27.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/time v0.11.0 // indirect
golang.org/x/tools v0.34.0 // indirect
golang.org/x/tools v0.36.0 // indirect
gonum.org/v1/gonum v0.16.0 // indirect
google.golang.org/api v0.236.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect
google.golang.org/grpc v1.74.2 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 // indirect
google.golang.org/grpc v1.75.1 // indirect
gopkg.in/telebot.v3 v3.3.8 // indirect
k8s.io/client-go v0.33.0 // indirect
k8s.io/client-go v0.34.0 // indirect
k8s.io/klog/v2 v2.130.1 // indirect
k8s.io/utils v0.0.0-20250321185631-1f6e0b77f77e // indirect
k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect
)

250
go.sum
View File

@@ -46,8 +46,8 @@ cloud.google.com/go/compute v1.3.0/go.mod h1:cCZiE1NHEtai4wiufUhW8I8S1JKkAnhnQJW
cloud.google.com/go/compute v1.5.0/go.mod h1:9SMHyhJlzhlkJqrPAc839t2BZFTSk6Jdj6mkzQJeu0M=
cloud.google.com/go/compute v1.6.0/go.mod h1:T29tfhtVbq1wvAPo0E3+7vhgmkOYeXjhFvz/FMzPu0s=
cloud.google.com/go/compute v1.6.1/go.mod h1:g85FgpzFvNULZ+S8AYq87axRKuf2Kh7deLqV/jJ3thU=
cloud.google.com/go/compute/metadata v0.7.0 h1:PBWF+iiAerVNe8UCHxdOt6eHLVc3ydFeOCw78U8ytSU=
cloud.google.com/go/compute/metadata v0.7.0/go.mod h1:j5MvL9PprKL39t166CoB1uVHfQMs4tFQZZcKwksXUjo=
cloud.google.com/go/compute/metadata v0.8.2 h1:9qL7VvAzeYdA4MN9QjJCSRO8h2vx8C5Rif+PnG5ITQ8=
cloud.google.com/go/compute/metadata v0.8.2/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
cloud.google.com/go/firestore v1.6.1/go.mod h1:asNXNOzBdyVQmEU+ggO8UPodTkEVFW5Qx+rwHnAz+EY=
@@ -106,8 +106,8 @@ github.com/SigNoz/govaluate v0.0.0-20240203125216-988004ccc7fd h1:Bk43AsDYe0fhkb
github.com/SigNoz/govaluate v0.0.0-20240203125216-988004ccc7fd/go.mod h1:nxRcH/OEdM8QxzH37xkGzomr1O0JpYBRS6pwjsWW6Pc=
github.com/SigNoz/signoz-otel-collector v0.129.4 h1:DGDu9y1I1FU+HX4eECPGmfhnXE4ys4yr7LL6znbf6to=
github.com/SigNoz/signoz-otel-collector v0.129.4/go.mod h1:xyR+coBzzO04p6Eu+ql2RVYUl/jFD+8hD9lArcc9U7g=
github.com/Yiling-J/theine-go v0.6.1 h1:njE/rBBviU/Sq2G7PJKdLdwXg8j1azvZQulIjmshD+o=
github.com/Yiling-J/theine-go v0.6.1/go.mod h1:08QpMa5JZ2pKN+UJCRrCasWYO1IKCdl54Xa836rpmDU=
github.com/Yiling-J/theine-go v0.6.2 h1:1GeoXeQ0O0AUkiwj2S9Jc0Mzx+hpqzmqsJ4kIC4M9AY=
github.com/Yiling-J/theine-go v0.6.2/go.mod h1:08QpMa5JZ2pKN+UJCRrCasWYO1IKCdl54Xa836rpmDU=
github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5/go.mod h1:SkGFH1ia65gfNATL8TAiHDNxPzPdmEL5uirI2Uyuz6c=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
@@ -159,8 +159,8 @@ github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dR
github.com/cactus/go-statsd-client/statsd v0.0.0-20200423205355-cb0885a1018c/go.mod h1:l/bIBLeOl9eX+wxJAzxS4TveKRtAqlyDpHjhkfO0MEI=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cenkalti/backoff/v5 v5.0.2 h1:rIfFVxEf1QsI7E1ZHfp/B4DF/6QBAUhmgkxc0H7Zss8=
github.com/cenkalti/backoff/v5 v5.0.2/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
@@ -210,10 +210,10 @@ github.com/digitalocean/godo v1.144.0 h1:rDCsmpwcDe5egFQ3Ae45HTde685/GzX037mWRMP
github.com/digitalocean/godo v1.144.0/go.mod h1:tYeiWY5ZXVpU48YaFv0M5irUFHXGorZpDNm7zzdWMzM=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI=
github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/docker v28.4.0+incompatible h1:KVC7bz5zJY/4AZe/78BIvCnPsLaC9T/zh72xnlrTTOk=
github.com/docker/docker v28.4.0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
@@ -229,8 +229,8 @@ github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1X
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-smtp v0.21.3 h1:7uVwagE8iPYE48WhNsng3RRpCUpFvNl39JGNSIyGVMY=
github.com/emersion/go-smtp v0.21.3/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g=
github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU=
github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
@@ -272,8 +272,8 @@ github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4
github.com/fsnotify/fsnotify v1.5.4/go.mod h1:OVB6XrOHzAwXMpEM7uPOzcehqUV2UqJxmVXmkdnm1bU=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/go-co-op/gocron v1.30.1 h1:tjWUvJl5KrcwpkEkSXFSQFr4F9h5SfV/m4+RX0cV2fs=
github.com/go-co-op/gocron v1.30.1/go.mod h1:39f6KNSGVOU1LO/ZOoZfcSxwlsJDQOKSu8erN0SH48Y=
@@ -284,8 +284,8 @@ github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7F
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
github.com/go-jose/go-jose/v4 v4.1.1 h1:JYhSgy4mXXzAdF3nUx3ygx347LRXJRrpgyU3adRmkAI=
github.com/go-jose/go-jose/v4 v4.1.1/go.mod h1:BdsZGqgdO3b6tTc6LSE56wcDbMMLuPsw5d4ZD5f94kA=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
@@ -336,8 +336,8 @@ github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI6
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
github.com/go-test/deep v1.0.2-0.20181118220953-042da051cf31/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
github.com/go-viper/mapstructure/v2 v2.3.0 h1:27XbWsHIqhbdR5TIC911OfYvgSaW93HM+dX7970Q7jk=
github.com/go-viper/mapstructure/v2 v2.3.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-zookeeper/zk v1.0.4 h1:DPzxraQx7OrPyXq2phlGlNSIyWEsAox0RJmjTseMV6I=
github.com/go-zookeeper/zk v1.0.4/go.mod h1:nOB03cncLtlp4t+UAkGSV+9beXP/akpekBwL+UX1Qcw=
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
@@ -398,10 +398,10 @@ github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Z
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
github.com/google/cel-go v0.26.0 h1:DPGjXackMpJWH680oGY4lZhYjIameYmR+/6RBdDGmaI=
github.com/google/cel-go v0.26.0/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM=
github.com/google/gnostic-models v0.6.9 h1:MU/8wDLif2qCXZmzncUQ/BOfxWfthHi63KqpoNbWqVw=
github.com/google/gnostic-models v0.6.9/go.mod h1:CiWsm0s6BSQd1hRn8/QmxqB6BesYcbSZxsz9b0KuDBw=
github.com/google/cel-go v0.26.1 h1:iPbVVEdkhTX++hpe3lzSk7D3G3QSYqLGoHOcEio+UXQ=
github.com/google/cel-go v0.26.1/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM=
github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
@@ -443,6 +443,8 @@ github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLe
github.com/google/pprof v0.0.0-20210601050228-01bbb1931b22/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
@@ -480,8 +482,8 @@ github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 h1:sGm2vDRFUrQJO/Veii4h4z
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2/go.mod h1:wd1YpapPLivG6nQgbf7ZkG1hhSOXDhhn4MLTknx2aAc=
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 h1:X5VWvz21y3gzm9Nw/kaUeku/1+uBhcekkmy4IkffJww=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs=
github.com/hashicorp/consul/api v1.12.0/go.mod h1:6pVBMo0ebnYdt2S3H87XhekM/HHrUoTD2XXb/VrZVy0=
github.com/hashicorp/consul/api v1.13.0/go.mod h1:ZlVrynguJKcYr54zGaDbaL3fOvKC9m72FhPvA8T35KQ=
github.com/hashicorp/consul/api v1.32.0 h1:5wp5u780Gri7c4OedGEPzmlUEzi0g2KyiPphSr6zjVg=
@@ -577,8 +579,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs=
github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jessevdk/go-flags v1.6.1 h1:Cvu5U8UGrLay1rZfv/zP7iLpSHGUZ/Ou68T0iX1bBK4=
@@ -720,8 +722,9 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
@@ -779,12 +782,12 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/openfga/api/proto v0.0.0-20250127102726-f9709139a369 h1:wEsCZ4oBuu8LfEJ3VXbveXO8uEhCthrxA40WSvxO044=
github.com/openfga/api/proto v0.0.0-20250127102726-f9709139a369/go.mod h1:m74TNgnAAIJ03gfHcx+xaRWnr+IbQy3y/AVNwwCFrC0=
github.com/openfga/api/proto v0.0.0-20250909172242-b4b2a12f5c67 h1:58mhO5nqkdka2Mpg5mijuZOHScX7reowhzRciwjFCU8=
github.com/openfga/api/proto v0.0.0-20250909172242-b4b2a12f5c67/go.mod h1:XDX4qYNBUM2Rsa2AbKPh+oocZc2zgme+EF2fFC6amVU=
github.com/openfga/language/pkg/go v0.2.0-beta.2.0.20250428093642-7aeebe78bbfe h1:X1g0rBUMvvzMudsak/jmoEZ1NhSsp6yR0VGxWHnGMzs=
github.com/openfga/language/pkg/go v0.2.0-beta.2.0.20250428093642-7aeebe78bbfe/go.mod h1:5Z0pbTT7Jz/oQFLfadb+C5t5NwHrduAO7j7L07Ec1GM=
github.com/openfga/openfga v1.9.5 h1:CtlyxP8D6cG1/EC5GLgJHSZYz4Wcbzh9b3jF10oMuw4=
github.com/openfga/openfga v1.9.5/go.mod h1:prbb9r4bAp24mYi/DQK97Q3fqiLivlH+8maopVNnxdE=
github.com/openfga/openfga v1.10.1 h1:iznHh7fgmJO+XhWPOJbPUwmE2r1ruoCRgjpPiB2D164=
github.com/openfga/openfga v1.10.1/go.mod h1:LAcl94t0m+2w2cP9VWmQkwAnn0jF9tsf4Oio0n/iaAE=
github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o=
github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs=
github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=
@@ -803,8 +806,8 @@ github.com/pelletier/go-toml v1.7.0/go.mod h1:vwGMzjaWMwyfHwgIBhI2YUM4fB6nL6lVAv
github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
github.com/pelletier/go-toml/v2 v2.0.5/go.mod h1:OMHamSCAODeSsVrwwvcJOaoN0LIUIaFVNZzmWyNfXas=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU=
github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
@@ -825,8 +828,8 @@ github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndr
github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/pressly/goose/v3 v3.24.3 h1:DSWWNwwggVUsYZ0X2VitiAa9sKuqtBfe+Jr9zFGwWlM=
github.com/pressly/goose/v3 v3.24.3/go.mod h1:v9zYL4xdViLHCUUJh/mhjnm6JrK7Eul8AS93IxiZM4E=
github.com/pressly/goose/v3 v3.25.0 h1:6WeYhMWGRCzpyd89SpODFnCBCKz41KrVbRT58nVjGng=
github.com/pressly/goose/v3 v3.25.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY=
github.com/prometheus/alertmanager v0.28.1 h1:BK5pCoAtaKg01BYRUJhEDV1tqJMEtYBGzPw8QdvnnvA=
github.com/prometheus/alertmanager v0.28.1/go.mod h1:0StpPUDDHi1VXeM7p2yYfeZgLVi/PPlt39vo9LQUHxM=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
@@ -834,8 +837,8 @@ github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5Fsn
github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU=
github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M=
github.com/prometheus/client_golang v1.11.1/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0=
github.com/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc=
github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
@@ -846,8 +849,8 @@ github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y8
github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4=
github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo=
github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc=
github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE=
github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
github.com/prometheus/exporter-toolkit v0.14.0 h1:NMlswfibpcZZ+H0sZBiTjrA3/aBFHkNZqE+iCj5EmRg=
github.com/prometheus/exporter-toolkit v0.14.0/go.mod h1:Gu5LnVvt7Nr/oqTBUC23WILZepW0nffNo10XdhQcwWA=
github.com/prometheus/otlptranslator v0.0.0-20250320144820-d800c8b0eb07 h1:YaJ1JqyKGIUFIMUpMeT22yewZMXiTt5sLgWG1D/m4Yc=
@@ -891,8 +894,8 @@ github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb
github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc=
github.com/sagikazarmark/crypt v0.6.0/go.mod h1:U8+INwJo3nBv1m6A/8OBXAq7Jnpspk5AxSgDyEQcea8=
github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
github.com/sagikazarmark/locafero v0.9.0 h1:GbgQGNtTrEmddYDSAH9QLRyfAHY12md+8YFTqyMTC9k=
github.com/sagikazarmark/locafero v0.9.0/go.mod h1:UBUyz37V+EdMS3hDF3QWIiVr/2dPrx49OMO0Bn0hJqk=
github.com/samber/lo v1.47.0 h1:z7RynLwP5nbyRscyvcD043DWYoOcYRv3mV8lBeqOCLc=
github.com/samber/lo v1.47.0/go.mod h1:RmDH9Ct32Qy3gduHQuKJ3gW1fMHAnE/fAzQuf6He5cU=
github.com/scaleway/scaleway-sdk-go v1.0.0-beta.33 h1:KhF0WejiUTDbL5X55nXowP7zNopwpowa6qaMAWyIE+0=
@@ -936,18 +939,18 @@ github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9yS
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/spf13/afero v1.8.2/go.mod h1:CtAatgMJh6bJEIs48Ay/FOnkljP3WeGUG0MC1RfAqwo=
github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs=
github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.5.0/go.mod h1:SpXXQ5YoyJw6s3/6cMTQuxvgRl3PCJiyaX9p6b155UU=
github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE=
github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M=
github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.13.0/go.mod h1:Icm2xNL3/8uyh/wFuB1jI7TiTNKp8632Nwegu+zgdYw=
github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4=
github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
@@ -975,8 +978,8 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.4.1/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
@@ -1163,16 +1166,16 @@ go.opentelemetry.io/contrib/config v0.10.0 h1:2JknAzMaYjxrHkTnZh3eOme/Y2P5eHE2SW
go.opentelemetry.io/contrib/config v0.10.0/go.mod h1:aND2M6/KfNkntI5cyvHriR/zvZgPf8j9yETdSmvpfmc=
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.60.0 h1:0tY123n7CdWMem7MOVdKOt0YfshufLCwfE5Bob+hQuM=
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.60.0/go.mod h1:CosX/aS4eHnG9D7nESYpV753l4j9q5j3SL/PUYd2lR8=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg=
go.opentelemetry.io/contrib/otelconf v0.16.0 h1:mTYGRlZtpc/zDaTaUQSnsZ1hyoRONaS4Od/Ny5++lhE=
go.opentelemetry.io/contrib/otelconf v0.16.0/go.mod h1:gnsljuyDyVDg39vUvXKj0BVCiVaokN3b8N5BL/ab8fQ=
go.opentelemetry.io/contrib/propagators/b3 v1.36.0 h1:xrAb/G80z/l5JL6XlmUMSD1i6W8vXkWrLfmkD3w/zZo=
go.opentelemetry.io/contrib/propagators/b3 v1.36.0/go.mod h1:UREJtqioFu5awNaCR8aEx7MfJROFlAWb6lPaJFbHaG0=
go.opentelemetry.io/contrib/zpages v0.61.0 h1:tYvUj377Dn3k1wf1le/f8YWSNQ8k0byS3jK8PiIXu9Y=
go.opentelemetry.io/contrib/zpages v0.61.0/go.mod h1:MFNPHMJOGA1P6m5501ANjOJDp4A9BUQja1Y53CDL8LQ=
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.12.2 h1:06ZeJRe5BnYXceSM9Vya83XXVaNGe3H1QqsvqRANQq8=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.12.2/go.mod h1:DvPtKE63knkDVP88qpatBj81JxN+w1bqfVbsbCbj1WY=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.12.2 h1:tPLwQlXbJ8NSOfZc4OkgU5h2A38M4c9kfHSVc4PFQGs=
@@ -1181,10 +1184,10 @@ go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.36.0 h1:zwd
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.36.0/go.mod h1:rUKCPscaRWWcqGT6HnEmYrK+YNe5+Sw64xgQTOJ5b30=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.36.0 h1:gAU726w9J8fwr4qRDqu1GYMNNs4gXrU+Pv20/N1UpB4=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.36.0/go.mod h1:RboSDkp7N292rgu+T0MgVt2qgFGu6qa1RpZDOtpL76w=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.36.0 h1:dNzwXjZKpMpE2JhmO+9HsPl42NIXFIFSUSSs0fiqra0=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.36.0/go.mod h1:90PoxvaEB5n6AOdZvi+yWJQoE95U8Dhhw2bSyRqnTD0=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.36.0 h1:JgtbA0xkWHnTmYk7YusopJFX6uleBmAuZ8n05NEh8nQ=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.36.0/go.mod h1:179AK5aar5R3eS9FucPy6rggvU0g52cvKId8pv4+v0c=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0/go.mod h1:ri3aaHSmCTVYu2AWv44YMauwAQc0aqI9gHKIcSbI1pU=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0 h1:lwI4Dc5leUqENgGuQImwLo4WnuXFPetmPpkLi2IrX54=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0/go.mod h1:Kz/oCE7z5wuyhPxsXDuaPteSWqjSBD5YaSdbxZYGbGk=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0 h1:nRVXXvf78e00EwY6Wp0YII8ww2JVWshZ20HfTlE11AM=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0/go.mod h1:r49hO7CgrxY9Voaj3Xe8pANWtr0Oq916d0XAmOoCZAQ=
go.opentelemetry.io/otel/exporters/prometheus v0.58.0 h1:CJAxWKFIqdBennqxJyOgnt5LqkeFRT+Mz3Yjz3hL+h8=
@@ -1199,21 +1202,21 @@ go.opentelemetry.io/otel/log v0.12.2 h1:yob9JVHn2ZY24byZeaXpTVoPS6l+UrrxmxmPKohX
go.opentelemetry.io/otel/log v0.12.2/go.mod h1:ShIItIxSYxufUMt+1H5a2wbckGli3/iCfuEbVZi/98E=
go.opentelemetry.io/otel/log/logtest v0.0.0-20250526142609-aa5bd0e64989 h1:4JF7oY9CcHrPGfBLijDcXZyCzGckVEyOjuat5ktmQRg=
go.opentelemetry.io/otel/log/logtest v0.0.0-20250526142609-aa5bd0e64989/go.mod h1:NToOxLDCS1tXDSB2dIj44H9xGPOpKr0csIN+gnuihv4=
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
go.opentelemetry.io/otel/sdk/log v0.12.2 h1:yNoETvTByVKi7wHvYS6HMcZrN5hFLD7I++1xIZ/k6W0=
go.opentelemetry.io/otel/sdk/log v0.12.2/go.mod h1:DcpdmUXHJgSqN/dh+XMWa7Vf89u9ap0/AAk/XGLnEzY=
go.opentelemetry.io/otel/sdk/log/logtest v0.0.0-20250521073539-a85ae98dcedc h1:uqxdywfHqqCl6LmZzI3pUnXT1RGFYyUgxj0AkWPFxi0=
go.opentelemetry.io/otel/sdk/log/logtest v0.0.0-20250521073539-a85ae98dcedc/go.mod h1:TY/N/FT7dmFrP/r5ym3g0yysP1DefqGpAZr4f82P0dE=
go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis=
go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4=
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI=
go.opentelemetry.io/proto/otlp v1.6.0 h1:jQjP+AQyTf+Fe7OKj/MfkDrmK4MNVtw2NpXsf9fefDI=
go.opentelemetry.io/proto/otlp v1.6.0/go.mod h1:cicgGehlFuNdgZkcALOCh3VE6K/u2tAjzlRhDwmVpZc=
go.opentelemetry.io/proto/otlp v1.8.0 h1:fRAZQDcAFHySxpJ1TwlA1cJ4tvcrw7nXl9xWWC8N5CE=
go.opentelemetry.io/proto/otlp v1.8.0/go.mod h1:tIeYOeNBU4cvmPqpaji1P+KbB4Oloai8wN4rWzRrFF0=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
@@ -1221,8 +1224,8 @@ go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0
go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
@@ -1232,8 +1235,8 @@ go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
go.yaml.in/yaml/v3 v3.0.3 h1:bXOww4E/J3f66rav3pX3m8w6jDE4knZjGOw8b5Y6iNE=
go.yaml.in/yaml/v3 v3.0.3/go.mod h1:tBHosrYAkRZjRAOREWbDnBXUf08JOwYq++0QNwQiWzI=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
@@ -1245,8 +1248,8 @@ golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm
golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -1284,8 +1287,8 @@ golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -1337,8 +1340,8 @@ golang.org/x/net v0.0.0-20220325170049-de3da57026de/go.mod h1:CfG3xpIq0wQ8r1q4Su
golang.org/x/net v0.0.0-20220412020605-290c469a71a5/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220520000938-2e3eb7b945c2/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -1373,8 +1376,8 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220513210516-0976fa681c29/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -1464,12 +1467,13 @@ golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220502124256-b6088ccd6cba/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg=
golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0=
golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -1480,8 +1484,8 @@ golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@@ -1545,8 +1549,8 @@ golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.3/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -1683,10 +1687,10 @@ google.golang.org/genproto v0.0.0-20220421151946-72621c1f0bd3/go.mod h1:8w6bsBMX
google.golang.org/genproto v0.0.0-20220429170224-98d788798c3e/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
google.golang.org/genproto v0.0.0-20220505152158-f39f71e6c8f3/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4=
google.golang.org/genproto v0.0.0-20220519153652-3a47de7e79bd/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4=
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 h1:oWVWY3NzT7KJppx2UKhKmzPq4SRe0LdCijVRwvGeikY=
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822/go.mod h1:h3c4v36UTKzUiuaOKQ6gr3S+0hovBtUrXzTG/i3+XEc=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 h1:BIRfGDEjiHRrk0QKZe3Xv2ieMhtgRGeLcZQ0mIVn4EY=
google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5/go.mod h1:j3QtIyytwqGr1JUDtYXwtMXWPKsEa5LtzIFN1Wn5WvE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 h1:eaY8u2EuxbRv7c3NiGK0/NedzVsCcV6hDuU5qPX5EGE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5/go.mod h1:M4/wBTSeyLxupu3W3tJtOgB14jILAS/XWPSSa3TAlJc=
google.golang.org/grpc v1.14.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
@@ -1719,8 +1723,8 @@ google.golang.org/grpc v1.44.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ5
google.golang.org/grpc v1.45.0/go.mod h1:lN7owxKUQEqMfSyQikvvk5tf/6zMPsrK+ONuO11+0rQ=
google.golang.org/grpc v1.46.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk=
google.golang.org/grpc v1.46.2/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk=
google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4=
google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM=
google.golang.org/grpc v1.75.1 h1:/ODCNEuf9VghjgO3rqLcfg8fiOP0nSluljWFlDxELLI=
google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
@@ -1736,8 +1740,8 @@ google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp0
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d/go.mod h1:cuepJuh7vyXfUyUwEgHQXw849cJrilpS5NeIjOWESAw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -1778,35 +1782,53 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
k8s.io/api v0.33.0 h1:yTgZVn1XEe6opVpP1FylmNrIFWuDqe2H0V8CT5gxfIU=
k8s.io/api v0.33.0/go.mod h1:CTO61ECK/KU7haa3qq8sarQ0biLq2ju405IZAd9zsiM=
k8s.io/apimachinery v0.33.0 h1:1a6kHrJxb2hs4t8EE5wuR/WxKDwGN1FKH3JvDtA0CIQ=
k8s.io/apimachinery v0.33.0/go.mod h1:BHW0YOu7n22fFv/JkYOEfkUYNRN0fj0BlvMFWA7b+SM=
k8s.io/client-go v0.33.0 h1:UASR0sAYVUzs2kYuKn/ZakZlcs2bEHaizrrHUZg0G98=
k8s.io/client-go v0.33.0/go.mod h1:kGkd+l/gNGg8GYWAPr0xF1rRKvVWvzh9vmZAMXtaKOg=
k8s.io/api v0.34.0 h1:L+JtP2wDbEYPUeNGbeSa/5GwFtIA662EmT2YSLOkAVE=
k8s.io/api v0.34.0/go.mod h1:YzgkIzOOlhl9uwWCZNqpw6RJy9L2FK4dlJeayUoydug=
k8s.io/apimachinery v0.34.0 h1:eR1WO5fo0HyoQZt1wdISpFDffnWOvFLOOeJ7MgIv4z0=
k8s.io/apimachinery v0.34.0/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw=
k8s.io/client-go v0.34.0 h1:YoWv5r7bsBfb0Hs2jh8SOvFbKzzxyNo0nSb0zC19KZo=
k8s.io/client-go v0.34.0/go.mod h1:ozgMnEKXkRjeMvBZdV1AijMHLTh3pbACPvK7zFR+QQY=
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff h1:/usPimJzUKKu+m+TE36gUyGcf03XZEP0ZIKgKj35LS4=
k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff/go.mod h1:5jIi+8yX4RIb8wk3XwBo5Pq2ccx4FP10ohkbSKCZoK8=
k8s.io/utils v0.0.0-20250321185631-1f6e0b77f77e h1:KqK5c/ghOm8xkHYhlodbp6i6+r+ChV2vuAuVRdFbLro=
k8s.io/utils v0.0.0-20250321185631-1f6e0b77f77e/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOPolHyvO06MXG5TUIj2mNAA=
k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts=
k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y=
k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM=
modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM=
modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek=
modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.39.0 h1:6bwu9Ooim0yVYA7IZn9demiQk/Ejp0BtTjBWFLymSeY=
modernc.org/sqlite v1.39.0/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 h1:/Rv+M11QRah1itp8VhT6HoVx1Ray9eB4DBr+K+/sCJ8=
sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3/go.mod h1:18nIHnGi6636UCz6m8i4DhaJ65T6EruyzmoQqI2BVDo=
sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE=
sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
sigs.k8s.io/structured-merge-diff/v4 v4.6.0 h1:IUA9nvMmnKWcj5jl84xn+T5MnlZKThmUW1TdblaLVAc=
sigs.k8s.io/structured-merge-diff/v4 v4.6.0/go.mod h1:dDy58f92j70zLsuZVuUX5Wp9vtxXpaZnkPGWeqDfCps=
sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco=
sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc=
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=

View File

@@ -55,6 +55,10 @@ type Alertmanager interface {
// SetDefaultConfig sets the default config for the organization.
SetDefaultConfig(context.Context, string) error
SetNotificationConfig(ctx context.Context, orgID valuer.UUID, ruleId string, config *alertmanagertypes.NotificationConfig) error
DeleteNotificationConfig(ctx context.Context, orgID valuer.UUID, ruleId string) error
// Collects stats for the organization.
statsreporter.StatsCollector
}

View File

@@ -0,0 +1,563 @@
package alertmanagerserver
import (
"context"
"fmt"
"log/slog"
"sort"
"sync"
"time"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/prometheus/alertmanager/dispatch"
"github.com/prometheus/alertmanager/notify"
"github.com/prometheus/alertmanager/provider"
"github.com/prometheus/alertmanager/store"
"github.com/prometheus/alertmanager/types"
"github.com/prometheus/common/model"
)
const (
noDataLabel = model.LabelName("nodata")
)
// Dispatcher sorts incoming alerts into aggregation groups and
// assigns the correct notifiers to each.
type Dispatcher struct {
route *dispatch.Route
alerts provider.Alerts
stage notify.Stage
marker types.GroupMarker
metrics *DispatcherMetrics
limits Limits
timeout func(time.Duration) time.Duration
mtx sync.RWMutex
aggrGroupsPerRoute map[*dispatch.Route]map[model.Fingerprint]*aggrGroup
aggrGroupsNum int
done chan struct{}
ctx context.Context
cancel func()
logger *slog.Logger
notificationManager nfmanager.NotificationManager
orgID string
}
// We use the upstream Limits interface from Prometheus
type Limits = dispatch.Limits
// NewDispatcher returns a new Dispatcher.
func NewDispatcher(
ap provider.Alerts,
r *dispatch.Route,
s notify.Stage,
mk types.GroupMarker,
to func(time.Duration) time.Duration,
lim Limits,
l *slog.Logger,
m *DispatcherMetrics,
n nfmanager.NotificationManager,
orgID string,
) *Dispatcher {
if lim == nil {
// Use a simple implementation when no limits are provided
lim = &unlimitedLimits{}
}
disp := &Dispatcher{
alerts: ap,
stage: s,
route: r,
marker: mk,
timeout: to,
logger: l.With("component", "signoz-dispatcher"),
metrics: m,
limits: lim,
notificationManager: n,
orgID: orgID,
}
return disp
}
// Run starts dispatching alerts incoming via the updates channel.
func (d *Dispatcher) Run() {
d.done = make(chan struct{})
d.mtx.Lock()
d.aggrGroupsPerRoute = map[*dispatch.Route]map[model.Fingerprint]*aggrGroup{}
d.aggrGroupsNum = 0
d.metrics.aggrGroups.Set(0)
d.ctx, d.cancel = context.WithCancel(context.Background())
d.mtx.Unlock()
d.run(d.alerts.Subscribe())
close(d.done)
}
func (d *Dispatcher) run(it provider.AlertIterator) {
maintenance := time.NewTicker(30 * time.Second)
defer maintenance.Stop()
defer it.Close()
for {
select {
case alert, ok := <-it.Next():
if !ok {
// Iterator exhausted for some reason.
if err := it.Err(); err != nil {
d.logger.ErrorContext(d.ctx, "Error on alert update", "err", err)
}
return
}
d.logger.DebugContext(d.ctx, "SigNoz Custom Dispatcher: Received alert", "alert", alert)
// Log errors but keep trying.
if err := it.Err(); err != nil {
d.logger.ErrorContext(d.ctx, "Error on alert update", "err", err)
continue
}
now := time.Now()
for _, r := range d.route.Match(alert.Labels) {
d.processAlert(alert, r)
}
d.metrics.processingDuration.Observe(time.Since(now).Seconds())
case <-maintenance.C:
d.doMaintenance()
case <-d.ctx.Done():
return
}
}
}
func (d *Dispatcher) doMaintenance() {
d.mtx.Lock()
defer d.mtx.Unlock()
for _, groups := range d.aggrGroupsPerRoute {
for _, ag := range groups {
if ag.empty() {
ag.stop()
d.marker.DeleteByGroupKey(ag.routeID, ag.GroupKey())
delete(groups, ag.fingerprint())
d.aggrGroupsNum--
d.metrics.aggrGroups.Dec()
}
}
}
}
// AlertGroup represents how alerts exist within an aggrGroup.
type AlertGroup struct {
Alerts types.AlertSlice
Labels model.LabelSet
Receiver string
GroupKey string
RouteID string
Renotify time.Duration
}
type AlertGroups []*AlertGroup
func (ag AlertGroups) Swap(i, j int) { ag[i], ag[j] = ag[j], ag[i] }
func (ag AlertGroups) Less(i, j int) bool {
if ag[i].Labels.Equal(ag[j].Labels) {
return ag[i].Receiver < ag[j].Receiver
}
return ag[i].Labels.Before(ag[j].Labels)
}
func (ag AlertGroups) Len() int { return len(ag) }
// Groups returns a slice of AlertGroups from the dispatcher's internal state.
func (d *Dispatcher) Groups(routeFilter func(*dispatch.Route) bool, alertFilter func(*types.Alert, time.Time) bool) (AlertGroups, map[model.Fingerprint][]string) {
groups := AlertGroups{}
d.mtx.RLock()
defer d.mtx.RUnlock()
// Keep a list of receivers for an alert to prevent checking each alert
// again against all routes. The alert has already matched against this
// route on ingestion.
receivers := map[model.Fingerprint][]string{}
now := time.Now()
for route, ags := range d.aggrGroupsPerRoute {
if !routeFilter(route) {
continue
}
for _, ag := range ags {
receiver := route.RouteOpts.Receiver
alertGroup := &AlertGroup{
Labels: ag.labels,
Receiver: receiver,
GroupKey: ag.GroupKey(),
RouteID: ag.routeID,
Renotify: ag.opts.RepeatInterval,
}
alerts := ag.alerts.List()
filteredAlerts := make([]*types.Alert, 0, len(alerts))
for _, a := range alerts {
if !alertFilter(a, now) {
continue
}
fp := a.Fingerprint()
if r, ok := receivers[fp]; ok {
// Receivers slice already exists. Add
// the current receiver to the slice.
receivers[fp] = append(r, receiver)
} else {
// First time we've seen this alert fingerprint.
// Initialize a new receivers slice.
receivers[fp] = []string{receiver}
}
filteredAlerts = append(filteredAlerts, a)
}
if len(filteredAlerts) == 0 {
continue
}
alertGroup.Alerts = filteredAlerts
groups = append(groups, alertGroup)
}
}
sort.Sort(groups)
for i := range groups {
sort.Sort(groups[i].Alerts)
}
for i := range receivers {
sort.Strings(receivers[i])
}
return groups, receivers
}
// Stop the dispatcher.
func (d *Dispatcher) Stop() {
if d == nil {
return
}
d.mtx.Lock()
if d.cancel == nil {
d.mtx.Unlock()
return
}
d.cancel()
d.cancel = nil
d.mtx.Unlock()
<-d.done
}
// notifyFunc is a function that performs notification for the alert
// with the given fingerprint. It aborts on context cancelation.
// Returns false iff notifying failed.
type notifyFunc func(context.Context, ...*types.Alert) bool
// processAlert determines in which aggregation group the alert falls
// and inserts it.
func (d *Dispatcher) processAlert(alert *types.Alert, route *dispatch.Route) {
ruleId := getRuleIDFromAlert(alert)
config, err := d.notificationManager.GetNotificationConfig(d.orgID, ruleId)
if err != nil {
d.logger.ErrorContext(d.ctx, "error getting alert notification config", "rule_id", ruleId, "error", err)
return
}
groupLabels := getGroupLabels(alert, config.NotificationGroup)
fp := groupLabels.Fingerprint()
d.mtx.Lock()
defer d.mtx.Unlock()
routeGroups, ok := d.aggrGroupsPerRoute[route]
if !ok {
routeGroups = map[model.Fingerprint]*aggrGroup{}
d.aggrGroupsPerRoute[route] = routeGroups
}
ag, ok := routeGroups[fp]
if ok {
ag.insert(alert)
return
}
// If the group does not exist, create it. But check the limit first.
if limit := d.limits.MaxNumberOfAggregationGroups(); limit > 0 && d.aggrGroupsNum >= limit {
d.metrics.aggrGroupLimitReached.Inc()
d.logger.ErrorContext(d.ctx, "Too many aggregation groups, cannot create new group for alert", "groups", d.aggrGroupsNum, "limit", limit, "alert", alert.Name())
return
}
renotifyInterval := config.Renotify.RenotifyInterval
if noDataAlert(alert) {
renotifyInterval = config.Renotify.NoDataInterval
groupLabels[noDataLabel] = alert.Labels[noDataLabel]
}
ag = newAggrGroup(d.ctx, groupLabels, route, d.timeout, d.logger, renotifyInterval)
routeGroups[fp] = ag
d.aggrGroupsNum++
d.metrics.aggrGroups.Inc()
// Insert the 1st alert in the group before starting the group's run()
// function, to make sure that when the run() will be executed the 1st
// alert is already there.
ag.insert(alert)
go ag.run(func(ctx context.Context, alerts ...*types.Alert) bool {
_, _, err := d.stage.Exec(ctx, d.logger, alerts...)
if err != nil {
logger := d.logger.With("num_alerts", len(alerts), "err", err)
if errors.Is(ctx.Err(), context.Canceled) {
// It is expected for the context to be canceled on
// configuration reload or shutdown. In this case, the
// message should only be logged at the debug level.
logger.DebugContext(ctx, "Notify for alerts failed")
} else {
logger.ErrorContext(ctx, "Notify for alerts failed")
}
}
return err == nil
})
}
// aggrGroup aggregates alert fingerprints into groups to which a
// common set of routing options applies.
// It emits notifications in the specified intervals.
type aggrGroup struct {
labels model.LabelSet
opts *dispatch.RouteOpts
logger *slog.Logger
routeID string
routeKey string
alerts *store.Alerts
ctx context.Context
cancel func()
done chan struct{}
next *time.Timer
timeout func(time.Duration) time.Duration
mtx sync.RWMutex
hasFlushed bool
}
// newAggrGroup returns a new aggregation group.
func newAggrGroup(ctx context.Context, labels model.LabelSet, r *dispatch.Route, to func(time.Duration) time.Duration, logger *slog.Logger, renotify time.Duration) *aggrGroup {
if to == nil {
to = func(d time.Duration) time.Duration { return d }
}
opts := deepCopyRouteOpts(r.RouteOpts, renotify)
ag := &aggrGroup{
labels: labels,
routeID: r.ID(),
routeKey: r.Key(),
opts: &opts,
timeout: to,
alerts: store.NewAlerts(),
done: make(chan struct{}),
}
ag.ctx, ag.cancel = context.WithCancel(ctx)
ag.logger = logger.With("aggr_group", ag)
// Set an initial one-time wait before flushing
// the first batch of notifications.
ag.next = time.NewTimer(ag.opts.GroupWait)
return ag
}
func (ag *aggrGroup) fingerprint() model.Fingerprint {
return ag.labels.Fingerprint()
}
func (ag *aggrGroup) GroupKey() string {
return fmt.Sprintf("%s:%s", ag.routeKey, ag.labels)
}
func (ag *aggrGroup) String() string {
return ag.GroupKey()
}
func (ag *aggrGroup) run(nf notifyFunc) {
defer close(ag.done)
defer ag.next.Stop()
for {
select {
case now := <-ag.next.C:
// Give the notifications time until the next flush to
// finish before terminating them.
ctx, cancel := context.WithTimeout(ag.ctx, ag.timeout(ag.opts.GroupInterval))
// The now time we retrieve from the ticker is the only reliable
// point of time reference for the subsequent notification pipeline.
// Calculating the current time directly is prone to flaky behavior,
// which usually only becomes apparent in tests.
ctx = notify.WithNow(ctx, now)
// Populate context with information needed along the pipeline.
ctx = notify.WithGroupKey(ctx, ag.GroupKey())
ctx = notify.WithGroupLabels(ctx, ag.labels)
ctx = notify.WithReceiverName(ctx, ag.opts.Receiver)
ctx = notify.WithRepeatInterval(ctx, ag.opts.RepeatInterval)
ctx = notify.WithMuteTimeIntervals(ctx, ag.opts.MuteTimeIntervals)
ctx = notify.WithActiveTimeIntervals(ctx, ag.opts.ActiveTimeIntervals)
ctx = notify.WithRouteID(ctx, ag.routeID)
// Wait the configured interval before calling flush again.
ag.mtx.Lock()
ag.next.Reset(ag.opts.GroupInterval)
ag.hasFlushed = true
ag.mtx.Unlock()
ag.flush(func(alerts ...*types.Alert) bool {
return nf(ctx, alerts...)
})
cancel()
case <-ag.ctx.Done():
return
}
}
}
func (ag *aggrGroup) stop() {
// Calling cancel will terminate all in-process notifications
// and the run() loop.
ag.cancel()
<-ag.done
}
// insert inserts the alert into the aggregation group.
func (ag *aggrGroup) insert(alert *types.Alert) {
if err := ag.alerts.Set(alert); err != nil {
ag.logger.ErrorContext(ag.ctx, "error on set alert", "err", err)
}
// Immediately trigger a flush if the wait duration for this
// alert is already over.
ag.mtx.Lock()
defer ag.mtx.Unlock()
if !ag.hasFlushed && alert.StartsAt.Add(ag.opts.GroupWait).Before(time.Now()) {
ag.next.Reset(0)
}
}
func (ag *aggrGroup) empty() bool {
return ag.alerts.Empty()
}
// flush sends notifications for all new alerts.
func (ag *aggrGroup) flush(notify func(...*types.Alert) bool) {
if ag.empty() {
return
}
var (
alerts = ag.alerts.List()
alertsSlice = make(types.AlertSlice, 0, len(alerts))
resolvedSlice = make(types.AlertSlice, 0, len(alerts))
now = time.Now()
)
for _, alert := range alerts {
a := *alert
// Ensure that alerts don't resolve as time move forwards.
if a.ResolvedAt(now) {
resolvedSlice = append(resolvedSlice, &a)
} else {
a.EndsAt = time.Time{}
}
alertsSlice = append(alertsSlice, &a)
}
sort.Stable(alertsSlice)
ag.logger.DebugContext(ag.ctx, "flushing", "alerts", fmt.Sprintf("%v", alertsSlice))
if notify(alertsSlice...) {
// Delete all resolved alerts as we just sent a notification for them,
// and we don't want to send another one. However, we need to make sure
// that each resolved alert has not fired again during the flush as then
// we would delete an active alert thinking it was resolved.
if err := ag.alerts.DeleteIfNotModified(resolvedSlice); err != nil {
ag.logger.ErrorContext(ag.ctx, "error on delete alerts", "err", err)
}
}
}
// unlimitedLimits provides unlimited aggregation groups for SigNoz
type unlimitedLimits struct{}
func (u *unlimitedLimits) MaxNumberOfAggregationGroups() int { return 0 }
func getRuleIDFromAlert(alert *types.Alert) string {
for name, value := range alert.Labels {
if string(name) == "ruleId" {
return string(value)
}
}
return ""
}
func deepCopyRouteOpts(opts dispatch.RouteOpts, renotify time.Duration) dispatch.RouteOpts {
newOpts := opts
if opts.GroupBy != nil {
newOpts.GroupBy = make(map[model.LabelName]struct{}, len(opts.GroupBy))
for k, v := range opts.GroupBy {
newOpts.GroupBy[k] = v
}
}
if opts.MuteTimeIntervals != nil {
newOpts.MuteTimeIntervals = make([]string, len(opts.MuteTimeIntervals))
copy(newOpts.MuteTimeIntervals, opts.MuteTimeIntervals)
}
if opts.ActiveTimeIntervals != nil {
newOpts.ActiveTimeIntervals = make([]string, len(opts.ActiveTimeIntervals))
copy(newOpts.ActiveTimeIntervals, opts.ActiveTimeIntervals)
}
if renotify > 0 {
newOpts.RepeatInterval = renotify
}
return newOpts
}
func getGroupLabels(alert *types.Alert, groups map[model.LabelName]struct{}) model.LabelSet {
groupLabels := model.LabelSet{}
for ln, lv := range alert.Labels {
if _, ok := groups[ln]; ok {
groupLabels[ln] = lv
}
}
return groupLabels
}
func noDataAlert(alert *types.Alert) bool {
if _, ok := alert.Labels[noDataLabel]; ok {
return true
} else {
return false
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,7 @@ import (
"time"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/prometheus/alertmanager/dispatch"
@@ -50,29 +51,31 @@ type Server struct {
stateStore alertmanagertypes.StateStore
// alertmanager primitives from upstream alertmanager
alerts *mem.Alerts
nflog *nflog.Log
dispatcher *dispatch.Dispatcher
dispatcherMetrics *dispatch.DispatcherMetrics
inhibitor *inhibit.Inhibitor
silencer *silence.Silencer
silences *silence.Silences
timeIntervals map[string][]timeinterval.TimeInterval
pipelineBuilder *notify.PipelineBuilder
marker *alertmanagertypes.MemMarker
tmpl *template.Template
wg sync.WaitGroup
stopc chan struct{}
alerts *mem.Alerts
nflog *nflog.Log
dispatcher *Dispatcher
dispatcherMetrics *DispatcherMetrics
inhibitor *inhibit.Inhibitor
silencer *silence.Silencer
silences *silence.Silences
timeIntervals map[string][]timeinterval.TimeInterval
pipelineBuilder *notify.PipelineBuilder
marker *alertmanagertypes.MemMarker
tmpl *template.Template
wg sync.WaitGroup
stopc chan struct{}
notificationManager nfmanager.NotificationManager
}
func New(ctx context.Context, logger *slog.Logger, registry prometheus.Registerer, srvConfig Config, orgID string, stateStore alertmanagertypes.StateStore) (*Server, error) {
func New(ctx context.Context, logger *slog.Logger, registry prometheus.Registerer, srvConfig Config, orgID string, stateStore alertmanagertypes.StateStore, nfManager nfmanager.NotificationManager) (*Server, error) {
server := &Server{
logger: logger.With("pkg", "go.signoz.io/pkg/alertmanager/alertmanagerserver"),
registry: registry,
srvConfig: srvConfig,
orgID: orgID,
stateStore: stateStore,
stopc: make(chan struct{}),
logger: logger.With("pkg", "go.signoz.io/pkg/alertmanager/alertmanagerserver"),
registry: registry,
srvConfig: srvConfig,
orgID: orgID,
stateStore: stateStore,
stopc: make(chan struct{}),
notificationManager: nfManager,
}
signozRegisterer := prometheus.WrapRegistererWithPrefix("signoz_", registry)
signozRegisterer = prometheus.WrapRegistererWith(prometheus.Labels{"org_id": server.orgID}, signozRegisterer)
@@ -190,7 +193,7 @@ func New(ctx context.Context, logger *slog.Logger, registry prometheus.Registere
}
server.pipelineBuilder = notify.NewPipelineBuilder(signozRegisterer, featurecontrol.NoopFlags{})
server.dispatcherMetrics = dispatch.NewDispatcherMetrics(false, signozRegisterer)
server.dispatcherMetrics = NewDispatcherMetrics(false, signozRegisterer)
return server, nil
}
@@ -204,7 +207,6 @@ func (server *Server) GetAlerts(ctx context.Context, params alertmanagertypes.Ge
func (server *Server) PutAlerts(ctx context.Context, postableAlerts alertmanagertypes.PostableAlerts) error {
alerts, err := alertmanagertypes.NewAlertsFromPostableAlerts(postableAlerts, time.Duration(server.srvConfig.Global.ResolveTimeout), time.Now())
// Notification sending alert takes precedence over validation errors.
if err := server.alerts.Put(alerts...); err != nil {
return err
@@ -295,7 +297,7 @@ func (server *Server) SetConfig(ctx context.Context, alertmanagerConfig *alertma
return d
}
server.dispatcher = dispatch.NewDispatcher(
server.dispatcher = NewDispatcher(
server.alerts,
routes,
pipeline,
@@ -304,6 +306,8 @@ func (server *Server) SetConfig(ctx context.Context, alertmanagerConfig *alertma
nil,
server.logger,
server.dispatcherMetrics,
server.notificationManager,
server.orgID,
)
// Do not try to add these to server.wg as there seems to be a race condition if

View File

@@ -11,6 +11,7 @@ import (
"testing"
"time"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager/nfmanagertest"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes/alertmanagertypestest"
"github.com/go-openapi/strfmt"
@@ -23,7 +24,8 @@ import (
)
func TestServerSetConfigAndStop(t *testing.T) {
server, err := New(context.Background(), slog.New(slog.NewTextHandler(io.Discard, nil)), prometheus.NewRegistry(), NewConfig(), "1", alertmanagertypestest.NewStateStore())
notificationManager := nfmanagertest.NewMock()
server, err := New(context.Background(), slog.New(slog.NewTextHandler(io.Discard, nil)), prometheus.NewRegistry(), NewConfig(), "1", alertmanagertypestest.NewStateStore(), notificationManager)
require.NoError(t, err)
amConfig, err := alertmanagertypes.NewDefaultConfig(alertmanagertypes.GlobalConfig{}, alertmanagertypes.RouteConfig{GroupInterval: 1 * time.Minute, RepeatInterval: 1 * time.Minute, GroupWait: 1 * time.Minute}, "1")
@@ -34,7 +36,8 @@ func TestServerSetConfigAndStop(t *testing.T) {
}
func TestServerTestReceiverTypeWebhook(t *testing.T) {
server, err := New(context.Background(), slog.New(slog.NewTextHandler(io.Discard, nil)), prometheus.NewRegistry(), NewConfig(), "1", alertmanagertypestest.NewStateStore())
notificationManager := nfmanagertest.NewMock()
server, err := New(context.Background(), slog.New(slog.NewTextHandler(io.Discard, nil)), prometheus.NewRegistry(), NewConfig(), "1", alertmanagertypestest.NewStateStore(), notificationManager)
require.NoError(t, err)
amConfig, err := alertmanagertypes.NewDefaultConfig(alertmanagertypes.GlobalConfig{}, alertmanagertypes.RouteConfig{GroupInterval: 1 * time.Minute, RepeatInterval: 1 * time.Minute, GroupWait: 1 * time.Minute}, "1")
@@ -81,7 +84,8 @@ func TestServerPutAlerts(t *testing.T) {
stateStore := alertmanagertypestest.NewStateStore()
srvCfg := NewConfig()
srvCfg.Route.GroupInterval = 1 * time.Second
server, err := New(context.Background(), slog.New(slog.NewTextHandler(io.Discard, nil)), prometheus.NewRegistry(), srvCfg, "1", stateStore)
notificationManager := nfmanagertest.NewMock()
server, err := New(context.Background(), slog.New(slog.NewTextHandler(io.Discard, nil)), prometheus.NewRegistry(), srvCfg, "1", stateStore, notificationManager)
require.NoError(t, err)
amConfig, err := alertmanagertypes.NewDefaultConfig(srvCfg.Global, srvCfg.Route, "1")

View File

@@ -0,0 +1,43 @@
package alertmanagerserver
import "github.com/prometheus/client_golang/prometheus"
type DispatcherMetrics struct {
aggrGroups prometheus.Gauge
processingDuration prometheus.Summary
aggrGroupLimitReached prometheus.Counter
}
// NewDispatcherMetrics returns a new registered DispatchMetrics.
// todo(aniketio-ctrl): change prom metrics to otel metrics
func NewDispatcherMetrics(registerLimitMetrics bool, r prometheus.Registerer) *DispatcherMetrics {
m := DispatcherMetrics{
aggrGroups: prometheus.NewGauge(
prometheus.GaugeOpts{
Name: "signoz_alertmanager_dispatcher_aggregation_groups",
Help: "Number of active aggregation groups",
},
),
processingDuration: prometheus.NewSummary(
prometheus.SummaryOpts{
Name: "signoz_alertmanager_dispatcher_alert_processing_duration_seconds",
Help: "Summary of latencies for the processing of alerts.",
},
),
aggrGroupLimitReached: prometheus.NewCounter(
prometheus.CounterOpts{
Name: "signoz_alertmanager_dispatcher_aggregation_group_limit_reached_total",
Help: "Number of times when dispatcher failed to create new aggregation group due to limit.",
},
),
}
if r != nil {
r.MustRegister(m.aggrGroups, m.processingDuration)
if registerLimitMetrics {
r.MustRegister(m.aggrGroupLimitReached)
}
}
return &m
}

View File

@@ -0,0 +1,18 @@
package nfmanager
import "github.com/SigNoz/signoz/pkg/factory"
type Config struct {
}
func NewConfigFactory() factory.ConfigFactory {
return factory.NewConfigFactory(factory.MustNewName("nfmanager"), newConfig)
}
func newConfig() factory.Config {
return Config{}
}
func (c Config) Validate() error {
return nil
}

View File

@@ -0,0 +1,75 @@
package nfmanagertest
import (
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
)
// MockNotificationManager is a simple mock implementation of NotificationManager
type MockNotificationManager struct {
configs map[string]*alertmanagertypes.NotificationConfig
errors map[string]error
}
// NewMock creates a new mock notification manager
func NewMock() *MockNotificationManager {
return &MockNotificationManager{
configs: make(map[string]*alertmanagertypes.NotificationConfig),
errors: make(map[string]error),
}
}
func getKey(orgId string, ruleId string) string {
return orgId + ":" + ruleId
}
func (m *MockNotificationManager) GetNotificationConfig(orgID string, ruleID string) (*alertmanagertypes.NotificationConfig, error) {
key := getKey(orgID, ruleID)
if err := m.errors[key]; err != nil {
return nil, err
}
if config := m.configs[key]; config != nil {
return config, nil
}
notificationConfig := alertmanagertypes.GetDefaultNotificationConfig()
return &notificationConfig, nil
}
func (m *MockNotificationManager) SetNotificationConfig(orgID string, ruleID string, config *alertmanagertypes.NotificationConfig) error {
key := getKey(orgID, ruleID)
if err := m.errors[key]; err != nil {
return err
}
m.configs[key] = config
return nil
}
func (m *MockNotificationManager) DeleteNotificationConfig(orgID string, ruleID string) error {
key := getKey(orgID, ruleID)
if err := m.errors[key]; err != nil {
return err
}
delete(m.configs, key)
return nil
}
func (m *MockNotificationManager) SetMockConfig(orgID, ruleID string, config *alertmanagertypes.NotificationConfig) {
key := getKey(orgID, ruleID)
m.configs[key] = config
}
func (m *MockNotificationManager) SetMockError(orgID, ruleID string, err error) {
key := getKey(orgID, ruleID)
m.errors[key] = err
}
func (m *MockNotificationManager) ClearMockData() {
m.configs = make(map[string]*alertmanagertypes.NotificationConfig)
m.errors = make(map[string]error)
}
func (m *MockNotificationManager) HasConfig(orgID, ruleID string) bool {
key := getKey(orgID, ruleID)
_, exists := m.configs[key]
return exists
}

View File

@@ -0,0 +1,13 @@
// Package nfmanager provides interfaces and implementations for alert notification grouping strategies.
package nfmanager
import (
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
)
// NotificationManager defines how alerts should be grouped and configured for notification with multi-tenancy support.
type NotificationManager interface {
GetNotificationConfig(orgID string, ruleID string) (*alertmanagertypes.NotificationConfig, error)
SetNotificationConfig(orgID string, ruleID string, config *alertmanagertypes.NotificationConfig) error
DeleteNotificationConfig(orgID string, ruleID string) error
}

View File

@@ -0,0 +1,103 @@
package rulebasednotification
import (
"context"
"sync"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/factory"
)
type provider struct {
settings factory.ScopedProviderSettings
orgToFingerprintToNotificationConfig map[string]map[string]alertmanagertypes.NotificationConfig
mutex sync.RWMutex
}
// NewFactory creates a new factory for the rule-based grouping strategy.
func NewFactory() factory.ProviderFactory[nfmanager.NotificationManager, nfmanager.Config] {
return factory.NewProviderFactory(
factory.MustNewName("rulebased"),
func(ctx context.Context, settings factory.ProviderSettings, config nfmanager.Config) (nfmanager.NotificationManager, error) {
return New(ctx, settings, config)
},
)
}
// New creates a new rule-based grouping strategy provider.
func New(ctx context.Context, providerSettings factory.ProviderSettings, config nfmanager.Config) (nfmanager.NotificationManager, error) {
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/alertmanager/nfmanager/rulebasednotification")
return &provider{
settings: settings,
orgToFingerprintToNotificationConfig: make(map[string]map[string]alertmanagertypes.NotificationConfig),
}, nil
}
// GetNotificationConfig retrieves the notification configuration for the specified alert and organization.
func (r *provider) GetNotificationConfig(orgID string, ruleID string) (*alertmanagertypes.NotificationConfig, error) {
notificationConfig := alertmanagertypes.GetDefaultNotificationConfig()
if orgID == "" || ruleID == "" {
return &notificationConfig, nil
}
r.mutex.RLock()
defer r.mutex.RUnlock()
if orgConfigs, exists := r.orgToFingerprintToNotificationConfig[orgID]; exists {
if config, configExists := orgConfigs[ruleID]; configExists {
if config.Renotify.RenotifyInterval != 0 {
notificationConfig.Renotify.RenotifyInterval = config.Renotify.RenotifyInterval
}
if config.Renotify.NoDataInterval != 0 {
notificationConfig.Renotify.NoDataInterval = config.Renotify.NoDataInterval
}
for k, v := range config.NotificationGroup {
notificationConfig.NotificationGroup[k] = v
}
}
}
return &notificationConfig, nil
}
// SetNotificationConfig updates the notification configuration for the specified alert and organization.
func (r *provider) SetNotificationConfig(orgID string, ruleID string, config *alertmanagertypes.NotificationConfig) error {
if orgID == "" || ruleID == "" {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "no org or rule id provided")
}
if config == nil {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "notification config cannot be nil")
}
r.mutex.Lock()
defer r.mutex.Unlock()
// Initialize org map if it doesn't exist
if r.orgToFingerprintToNotificationConfig[orgID] == nil {
r.orgToFingerprintToNotificationConfig[orgID] = make(map[string]alertmanagertypes.NotificationConfig)
}
r.orgToFingerprintToNotificationConfig[orgID][ruleID] = config.DeepCopy()
return nil
}
func (r *provider) DeleteNotificationConfig(orgID string, ruleID string) error {
if orgID == "" || ruleID == "" {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "no org or rule id provided")
}
r.mutex.Lock()
defer r.mutex.Unlock()
if _, exists := r.orgToFingerprintToNotificationConfig[orgID]; exists {
delete(r.orgToFingerprintToNotificationConfig[orgID], ruleID)
}
return nil
}

View File

@@ -0,0 +1,270 @@
package rulebasednotification
import (
"context"
"github.com/prometheus/common/model"
"sync"
"testing"
"time"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/prometheus/alertmanager/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func createTestProviderSettings() factory.ProviderSettings {
return instrumentationtest.New().ToProviderSettings()
}
func TestNewFactory(t *testing.T) {
providerFactory := NewFactory()
assert.NotNil(t, providerFactory)
assert.Equal(t, "rulebased", providerFactory.Name().String())
}
func TestNew(t *testing.T) {
ctx := context.Background()
providerSettings := createTestProviderSettings()
config := nfmanager.Config{}
provider, err := New(ctx, providerSettings, config)
require.NoError(t, err)
assert.NotNil(t, provider)
// Verify provider implements the interface correctly
assert.Implements(t, (*nfmanager.NotificationManager)(nil), provider)
}
func TestProvider_SetNotificationConfig(t *testing.T) {
ctx := context.Background()
providerSettings := createTestProviderSettings()
config := nfmanager.Config{}
provider, err := New(ctx, providerSettings, config)
require.NoError(t, err)
tests := []struct {
name string
orgID string
ruleID string
config *alertmanagertypes.NotificationConfig
wantErr bool
}{
{
name: "valid parameters",
orgID: "org1",
ruleID: "rule1",
config: &alertmanagertypes.NotificationConfig{
Renotify: alertmanagertypes.ReNotificationConfig{
RenotifyInterval: 2 * time.Hour,
NoDataInterval: 2 * time.Hour,
},
},
wantErr: false,
},
{
name: "empty orgID",
orgID: "",
ruleID: "rule1",
config: &alertmanagertypes.NotificationConfig{
Renotify: alertmanagertypes.ReNotificationConfig{
RenotifyInterval: time.Hour,
NoDataInterval: time.Hour,
},
},
wantErr: true, // Should error due to validation
},
{
name: "empty ruleID",
orgID: "org1",
ruleID: "",
config: &alertmanagertypes.NotificationConfig{
Renotify: alertmanagertypes.ReNotificationConfig{
RenotifyInterval: time.Hour,
NoDataInterval: time.Hour,
},
},
wantErr: true, // Should error due to validation
},
{
name: "nil config",
orgID: "org1",
ruleID: "rule1",
config: nil,
wantErr: true, // Should error due to nil config
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := provider.SetNotificationConfig(tt.orgID, tt.ruleID, tt.config)
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
// If we set a config successfully, we should be able to retrieve it
if tt.orgID != "" && tt.ruleID != "" && tt.config != nil {
retrievedConfig, retrieveErr := provider.GetNotificationConfig(tt.orgID, tt.ruleID)
assert.NoError(t, retrieveErr)
assert.NotNil(t, retrievedConfig)
assert.Equal(t, tt.config.Renotify, retrievedConfig.Renotify)
}
}
})
}
}
func TestProvider_GetNotificationConfig(t *testing.T) {
ctx := context.Background()
providerSettings := createTestProviderSettings()
config := nfmanager.Config{}
provider, err := New(ctx, providerSettings, config)
require.NoError(t, err)
orgID := "test-org"
ruleID := "rule1"
customConfig := &alertmanagertypes.NotificationConfig{
Renotify: alertmanagertypes.ReNotificationConfig{
RenotifyInterval: 30 * time.Minute,
NoDataInterval: 30 * time.Minute,
},
}
ruleId1 := "rule-1"
customConfig1 := &alertmanagertypes.NotificationConfig{
NotificationGroup: map[model.LabelName]struct{}{
model.LabelName("group1"): {},
model.LabelName("group2"): {},
},
}
// Set config for alert1
err = provider.SetNotificationConfig(orgID, ruleID, customConfig)
require.NoError(t, err)
err = provider.SetNotificationConfig(orgID, ruleId1, customConfig1)
require.NoError(t, err)
tests := []struct {
name string
orgID string
ruleID string
alert *types.Alert
expectedConfig *alertmanagertypes.NotificationConfig
shouldFallback bool
}{
{
name: "existing config",
orgID: orgID,
ruleID: ruleID,
expectedConfig: &alertmanagertypes.NotificationConfig{
NotificationGroup: map[model.LabelName]struct{}{
model.LabelName("ruleId"): {},
},
Renotify: alertmanagertypes.ReNotificationConfig{
RenotifyInterval: 30 * time.Minute,
NoDataInterval: 30 * time.Minute,
},
},
shouldFallback: false,
},
{
name: "non-existing config - fallback",
orgID: orgID,
ruleID: ruleId1,
expectedConfig: &alertmanagertypes.NotificationConfig{
NotificationGroup: map[model.LabelName]struct{}{
model.LabelName("group1"): {},
model.LabelName("group2"): {},
model.LabelName("ruleId"): {},
},
Renotify: alertmanagertypes.ReNotificationConfig{
RenotifyInterval: 4 * time.Hour,
NoDataInterval: 4 * time.Hour,
},
}, // Will get fallback from standardnotification
shouldFallback: false,
},
{
name: "empty orgID - fallback",
orgID: "",
ruleID: ruleID,
expectedConfig: nil, // Will get fallback
shouldFallback: true,
},
{
name: "nil alert - fallback",
orgID: orgID,
ruleID: "rule3", // Different ruleID to get fallback
alert: nil,
expectedConfig: nil, // Will get fallback
shouldFallback: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
config, err := provider.GetNotificationConfig(tt.orgID, tt.ruleID)
assert.NoError(t, err)
if tt.shouldFallback {
// Should get fallback config (4 hour default)
assert.NotNil(t, config)
assert.Equal(t, 4*time.Hour, config.Renotify.RenotifyInterval)
} else {
// Should get our custom config
assert.NotNil(t, config)
assert.Equal(t, tt.expectedConfig, config)
}
})
}
}
func TestProvider_ConcurrentAccess(t *testing.T) {
ctx := context.Background()
providerSettings := createTestProviderSettings()
config := nfmanager.Config{}
provider, err := New(ctx, providerSettings, config)
require.NoError(t, err)
orgID := "test-org"
var wg sync.WaitGroup
// Writer goroutine
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < 50; i++ {
config := &alertmanagertypes.NotificationConfig{
Renotify: alertmanagertypes.ReNotificationConfig{
RenotifyInterval: time.Duration(i+1) * time.Minute,
NoDataInterval: time.Duration(i+1) * time.Minute,
},
}
err := provider.SetNotificationConfig(orgID, "rule1", config)
assert.NoError(t, err)
}
}()
// Reader goroutine
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < 50; i++ {
config, err := provider.GetNotificationConfig(orgID, "rule1")
assert.NoError(t, err)
assert.NotNil(t, config)
}
}()
// Wait for both goroutines to complete
wg.Wait()
}

View File

@@ -5,6 +5,7 @@ import (
"sync"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagerserver"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/modules/organization"
@@ -32,6 +33,8 @@ type Service struct {
// Mutex to protect the servers map
serversMtx sync.RWMutex
notificationManager nfmanager.NotificationManager
}
func New(
@@ -41,15 +44,17 @@ func New(
stateStore alertmanagertypes.StateStore,
configStore alertmanagertypes.ConfigStore,
orgGetter organization.Getter,
nfManager nfmanager.NotificationManager,
) *Service {
service := &Service{
config: config,
stateStore: stateStore,
configStore: configStore,
orgGetter: orgGetter,
settings: settings,
servers: make(map[string]*alertmanagerserver.Server),
serversMtx: sync.RWMutex{},
config: config,
stateStore: stateStore,
configStore: configStore,
orgGetter: orgGetter,
settings: settings,
servers: make(map[string]*alertmanagerserver.Server),
serversMtx: sync.RWMutex{},
notificationManager: nfManager,
}
return service
@@ -167,7 +172,7 @@ func (service *Service) newServer(ctx context.Context, orgID string) (*alertmana
return nil, err
}
server, err := alertmanagerserver.New(ctx, service.settings.Logger(), service.settings.PrometheusRegisterer(), service.config, orgID, service.stateStore)
server, err := alertmanagerserver.New(ctx, service.settings.Logger(), service.settings.PrometheusRegisterer(), service.config, orgID, service.stateStore, service.notificationManager)
if err != nil {
return nil, err
}

View File

@@ -6,6 +6,7 @@ import (
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagerstore/sqlalertmanagerstore"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/modules/organization"
@@ -15,21 +16,22 @@ import (
)
type provider struct {
service *alertmanager.Service
config alertmanager.Config
settings factory.ScopedProviderSettings
configStore alertmanagertypes.ConfigStore
stateStore alertmanagertypes.StateStore
stopC chan struct{}
service *alertmanager.Service
config alertmanager.Config
settings factory.ScopedProviderSettings
configStore alertmanagertypes.ConfigStore
stateStore alertmanagertypes.StateStore
notificationManager nfmanager.NotificationManager
stopC chan struct{}
}
func NewFactory(sqlstore sqlstore.SQLStore, orgGetter organization.Getter) factory.ProviderFactory[alertmanager.Alertmanager, alertmanager.Config] {
func NewFactory(sqlstore sqlstore.SQLStore, orgGetter organization.Getter, notificationManager nfmanager.NotificationManager) factory.ProviderFactory[alertmanager.Alertmanager, alertmanager.Config] {
return factory.NewProviderFactory(factory.MustNewName("signoz"), func(ctx context.Context, settings factory.ProviderSettings, config alertmanager.Config) (alertmanager.Alertmanager, error) {
return New(ctx, settings, config, sqlstore, orgGetter)
return New(ctx, settings, config, sqlstore, orgGetter, notificationManager)
})
}
func New(ctx context.Context, providerSettings factory.ProviderSettings, config alertmanager.Config, sqlstore sqlstore.SQLStore, orgGetter organization.Getter) (*provider, error) {
func New(ctx context.Context, providerSettings factory.ProviderSettings, config alertmanager.Config, sqlstore sqlstore.SQLStore, orgGetter organization.Getter, notificationManager nfmanager.NotificationManager) (*provider, error) {
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/alertmanager/signozalertmanager")
configStore := sqlalertmanagerstore.NewConfigStore(sqlstore)
stateStore := sqlalertmanagerstore.NewStateStore(sqlstore)
@@ -42,12 +44,14 @@ func New(ctx context.Context, providerSettings factory.ProviderSettings, config
stateStore,
configStore,
orgGetter,
notificationManager,
),
settings: settings,
config: config,
configStore: configStore,
stateStore: stateStore,
stopC: make(chan struct{}),
settings: settings,
config: config,
configStore: configStore,
stateStore: stateStore,
notificationManager: notificationManager,
stopC: make(chan struct{}),
}
return p, nil
@@ -191,3 +195,19 @@ func (provider *provider) Collect(ctx context.Context, orgID valuer.UUID) (map[s
return alertmanagertypes.NewStatsFromChannels(channels), nil
}
func (provider *provider) SetNotificationConfig(ctx context.Context, orgID valuer.UUID, ruleId string, config *alertmanagertypes.NotificationConfig) error {
err := provider.notificationManager.SetNotificationConfig(orgID.StringValue(), ruleId, config)
if err != nil {
return err
}
return nil
}
func (provider *provider) DeleteNotificationConfig(ctx context.Context, orgID valuer.UUID, ruleId string) error {
err := provider.notificationManager.DeleteNotificationConfig(orgID.StringValue(), ruleId)
if err != nil {
return err
}
return nil
}

View File

@@ -6,12 +6,16 @@ import (
"github.com/openfga/openfga/pkg/storage"
"github.com/openfga/openfga/pkg/storage/postgres"
"github.com/openfga/openfga/pkg/storage/sqlcommon"
"github.com/openfga/openfga/pkg/storage/sqlite"
)
func NewSQLStore(sqlstore sqlstore.SQLStore) (storage.OpenFGADatastore, error) {
switch sqlstore.BunDB().Dialect().Name().String() {
// use the NewWithDB for sqlite once https://github.com/openfga/openfga/pull/2679 is merged and released else will figure out something else.
case "sqlite":
return sqlite.NewWithDB(sqlstore.SQLDB(), &sqlcommon.Config{
MaxTuplesPerWriteField: 100,
MaxTypesPerModelField: 100,
})
case "pg":
return postgres.NewWithDB(sqlstore.SQLDB(), nil, &sqlcommon.Config{
MaxTuplesPerWriteField: 100,

View File

@@ -11,7 +11,7 @@ import (
contribsdkconfig "go.opentelemetry.io/contrib/config"
sdkmetric "go.opentelemetry.io/otel/metric"
sdkresource "go.opentelemetry.io/otel/sdk/resource"
semconv "go.opentelemetry.io/otel/semconv/v1.34.0"
semconv "go.opentelemetry.io/otel/semconv/v1.37.0"
sdktrace "go.opentelemetry.io/otel/trace"
)

View File

@@ -1,14 +1,17 @@
package thirdpartyapi
import (
"context"
"fmt"
"github.com/SigNoz/signoz/pkg/types/thirdpartyapitypes"
"net"
"regexp"
"time"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
const (
@@ -19,6 +22,149 @@ const (
serverAddressKey = "server.address"
)
type ColumnAvailability struct {
HttpURL bool
URLFull bool
NetPeerName bool
ServerAddress bool
}
func CheckColumnAvailability(ctx context.Context, querier interface{}, orgID interface{}, start, end uint64) *ColumnAvailability {
availability := &ColumnAvailability{
HttpURL: true,
URLFull: false,
NetPeerName: true,
ServerAddress: false,
}
var orgUUID valuer.UUID
if uuid, ok := orgID.(valuer.UUID); ok {
orgUUID = uuid
} else {
return availability
}
testQueries := map[string]*bool{
urlPathKey: &availability.URLFull,
serverAddressKey: &availability.ServerAddress,
}
for attrKey, availabilityFlag := range testQueries {
testQuery := &qbtypes.QueryRangeRequest{
SchemaVersion: "v5",
Start: start,
End: end,
RequestType: qbtypes.RequestTypeScalar,
CompositeQuery: qbtypes.CompositeQuery{
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Name: "test",
Signal: telemetrytypes.SignalTraces,
StepInterval: qbtypes.Step{Duration: defaultStepInterval},
Aggregations: []qbtypes.TraceAggregation{
{Expression: "count()"},
},
Filter: &qbtypes.Filter{
Expression: fmt.Sprintf("%s EXISTS AND kind_string = 'Client'", attrKey),
},
Limit: 1,
},
},
},
},
}
if q, ok := querier.(interface {
QueryRange(context.Context, valuer.UUID, *v3.QueryRangeParamsV3) ([]*v3.Result, map[string]error, error)
}); ok {
v3CompositeQuery := &v3.CompositeQuery{
Queries: testQuery.CompositeQuery.Queries,
}
v3Query := &v3.QueryRangeParamsV3{
Start: int64(start * 1000),
End: int64(end * 1000),
CompositeQuery: v3CompositeQuery,
}
_, queryErrors, err := q.QueryRange(ctx, orgUUID, v3Query)
if err == nil && len(queryErrors) == 0 {
*availabilityFlag = true
} else {
*availabilityFlag = false
}
}
}
return availability
}
func getAvailableServerGroupBy(availability *ColumnAvailability) []qbtypes.GroupByKey {
var groupByKeys []qbtypes.GroupByKey
if availability.ServerAddress && availability.NetPeerName {
groupByKeys = dualSemconvGroupByKeys["server"]
} else if availability.ServerAddress {
groupByKeys = []qbtypes.GroupByKey{
{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: serverAddressKey,
FieldDataType: telemetrytypes.FieldDataTypeString,
FieldContext: telemetrytypes.FieldContextAttribute,
Signal: telemetrytypes.SignalTraces,
},
},
}
} else {
groupByKeys = []qbtypes.GroupByKey{
{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: serverAddressKeyLegacy,
FieldDataType: telemetrytypes.FieldDataTypeString,
FieldContext: telemetrytypes.FieldContextAttribute,
Signal: telemetrytypes.SignalTraces,
},
},
}
}
return groupByKeys
}
func getAvailableURLGroupBy(availability *ColumnAvailability) []qbtypes.GroupByKey {
var groupByKeys []qbtypes.GroupByKey
if availability.URLFull && availability.HttpURL {
groupByKeys = dualSemconvGroupByKeys["url"]
} else if availability.URLFull {
groupByKeys = []qbtypes.GroupByKey{
{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: urlPathKey,
FieldDataType: telemetrytypes.FieldDataTypeString,
FieldContext: telemetrytypes.FieldContextAttribute,
Signal: telemetrytypes.SignalTraces,
},
},
}
} else {
groupByKeys = []qbtypes.GroupByKey{
{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: urlPathKeyLegacy,
FieldDataType: telemetrytypes.FieldDataTypeString,
FieldContext: telemetrytypes.FieldContextAttribute,
Signal: telemetrytypes.SignalTraces,
},
},
}
}
return groupByKeys
}
var defaultStepInterval = 60 * time.Second
type SemconvFieldMapping struct {
@@ -211,6 +357,29 @@ func isValidValue(val any) bool {
return true
}
func FilterNullValues(result *qbtypes.QueryRangeResponse) *qbtypes.QueryRangeResponse {
if result == nil || result.Data.Results == nil {
return result
}
for _, res := range result.Data.Results {
scalarData, ok := res.(*qbtypes.ScalarData)
if !ok {
continue
}
filteredData := make([][]any, 0)
for _, row := range scalarData.Data {
if len(row) > 0 && isValidValue(row[0]) {
filteredData = append(filteredData, row)
}
}
scalarData.Data = filteredData
}
return result
}
func FilterResponse(results []*qbtypes.QueryRangeResponse) []*qbtypes.QueryRangeResponse {
filteredResults := make([]*qbtypes.QueryRangeResponse, 0, len(results))
@@ -296,18 +465,18 @@ func mergeGroupBy(base, additional []qbtypes.GroupByKey) []qbtypes.GroupByKey {
return append(base, additional...)
}
func BuildDomainList(req *thirdpartyapitypes.ThirdPartyApiRequest) (*qbtypes.QueryRangeRequest, error) {
func BuildDomainList(req *thirdpartyapitypes.ThirdPartyApiRequest, availability *ColumnAvailability) (*qbtypes.QueryRangeRequest, error) {
if err := req.Validate(); err != nil {
return nil, err
}
queries := []qbtypes.QueryEnvelope{
buildEndpointsQuery(req),
buildLastSeenQuery(req),
buildRpsQuery(req),
buildErrorQuery(req),
buildTotalSpanQuery(req),
buildP99Query(req),
buildEndpointsQuery(req, availability),
buildLastSeenQuery(req, availability),
buildRpsQuery(req, availability),
buildErrorQuery(req, availability),
buildTotalSpanQuery(req, availability),
buildP99Query(req, availability),
buildErrorRateFormula(),
}
@@ -325,16 +494,16 @@ func BuildDomainList(req *thirdpartyapitypes.ThirdPartyApiRequest) (*qbtypes.Que
}, nil
}
func BuildDomainInfo(req *thirdpartyapitypes.ThirdPartyApiRequest) (*qbtypes.QueryRangeRequest, error) {
func BuildDomainInfo(req *thirdpartyapitypes.ThirdPartyApiRequest, availability *ColumnAvailability) (*qbtypes.QueryRangeRequest, error) {
if err := req.Validate(); err != nil {
return nil, err
}
queries := []qbtypes.QueryEnvelope{
buildEndpointsInfoQuery(req),
buildP99InfoQuery(req),
buildErrorRateInfoQuery(req),
buildLastSeenInfoQuery(req),
buildEndpointsInfoQuery(req, availability),
buildP99InfoQuery(req, availability),
buildErrorRateInfoQuery(req, availability),
buildLastSeenInfoQuery(req, availability),
}
return &qbtypes.QueryRangeRequest{
@@ -351,7 +520,7 @@ func BuildDomainInfo(req *thirdpartyapitypes.ThirdPartyApiRequest) (*qbtypes.Que
}, nil
}
func buildEndpointsQuery(req *thirdpartyapitypes.ThirdPartyApiRequest) qbtypes.QueryEnvelope {
func buildEndpointsQuery(req *thirdpartyapitypes.ThirdPartyApiRequest, availability *ColumnAvailability) qbtypes.QueryEnvelope {
return qbtypes.QueryEnvelope{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
@@ -361,13 +530,13 @@ func buildEndpointsQuery(req *thirdpartyapitypes.ThirdPartyApiRequest) qbtypes.Q
Aggregations: []qbtypes.TraceAggregation{
{Expression: "count_distinct(http.url)"},
},
Filter: buildBaseFilter(req.Filter),
GroupBy: mergeGroupBy(dualSemconvGroupByKeys["server"], req.GroupBy),
Filter: buildBaseFilter(req.Filter, availability),
GroupBy: mergeGroupBy(getAvailableServerGroupBy(availability), req.GroupBy),
},
}
}
func buildLastSeenQuery(req *thirdpartyapitypes.ThirdPartyApiRequest) qbtypes.QueryEnvelope {
func buildLastSeenQuery(req *thirdpartyapitypes.ThirdPartyApiRequest, availability *ColumnAvailability) qbtypes.QueryEnvelope {
return qbtypes.QueryEnvelope{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
@@ -377,13 +546,13 @@ func buildLastSeenQuery(req *thirdpartyapitypes.ThirdPartyApiRequest) qbtypes.Qu
Aggregations: []qbtypes.TraceAggregation{
{Expression: "max(timestamp)"},
},
Filter: buildBaseFilter(req.Filter),
GroupBy: mergeGroupBy(dualSemconvGroupByKeys["server"], req.GroupBy),
Filter: buildBaseFilter(req.Filter, availability),
GroupBy: mergeGroupBy(getAvailableServerGroupBy(availability), req.GroupBy),
},
}
}
func buildRpsQuery(req *thirdpartyapitypes.ThirdPartyApiRequest) qbtypes.QueryEnvelope {
func buildRpsQuery(req *thirdpartyapitypes.ThirdPartyApiRequest, availability *ColumnAvailability) qbtypes.QueryEnvelope {
return qbtypes.QueryEnvelope{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
@@ -393,13 +562,13 @@ func buildRpsQuery(req *thirdpartyapitypes.ThirdPartyApiRequest) qbtypes.QueryEn
Aggregations: []qbtypes.TraceAggregation{
{Expression: "rate()"},
},
Filter: buildBaseFilter(req.Filter),
GroupBy: mergeGroupBy(dualSemconvGroupByKeys["server"], req.GroupBy),
Filter: buildBaseFilter(req.Filter, availability),
GroupBy: mergeGroupBy(getAvailableServerGroupBy(availability), req.GroupBy),
},
}
}
func buildErrorQuery(req *thirdpartyapitypes.ThirdPartyApiRequest) qbtypes.QueryEnvelope {
func buildErrorQuery(req *thirdpartyapitypes.ThirdPartyApiRequest, availability *ColumnAvailability) qbtypes.QueryEnvelope {
return qbtypes.QueryEnvelope{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
@@ -409,13 +578,13 @@ func buildErrorQuery(req *thirdpartyapitypes.ThirdPartyApiRequest) qbtypes.Query
Aggregations: []qbtypes.TraceAggregation{
{Expression: "count()"},
},
Filter: buildErrorFilter(req.Filter),
GroupBy: mergeGroupBy(dualSemconvGroupByKeys["server"], req.GroupBy),
Filter: buildErrorFilter(req.Filter, availability),
GroupBy: mergeGroupBy(getAvailableServerGroupBy(availability), req.GroupBy),
},
}
}
func buildTotalSpanQuery(req *thirdpartyapitypes.ThirdPartyApiRequest) qbtypes.QueryEnvelope {
func buildTotalSpanQuery(req *thirdpartyapitypes.ThirdPartyApiRequest, availability *ColumnAvailability) qbtypes.QueryEnvelope {
return qbtypes.QueryEnvelope{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
@@ -425,13 +594,13 @@ func buildTotalSpanQuery(req *thirdpartyapitypes.ThirdPartyApiRequest) qbtypes.Q
Aggregations: []qbtypes.TraceAggregation{
{Expression: "count()"},
},
Filter: buildBaseFilter(req.Filter),
GroupBy: mergeGroupBy(dualSemconvGroupByKeys["server"], req.GroupBy),
Filter: buildBaseFilter(req.Filter, availability),
GroupBy: mergeGroupBy(getAvailableServerGroupBy(availability), req.GroupBy),
},
}
}
func buildP99Query(req *thirdpartyapitypes.ThirdPartyApiRequest) qbtypes.QueryEnvelope {
func buildP99Query(req *thirdpartyapitypes.ThirdPartyApiRequest, availability *ColumnAvailability) qbtypes.QueryEnvelope {
return qbtypes.QueryEnvelope{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
@@ -441,8 +610,8 @@ func buildP99Query(req *thirdpartyapitypes.ThirdPartyApiRequest) qbtypes.QueryEn
Aggregations: []qbtypes.TraceAggregation{
{Expression: "p99(duration_nano)"},
},
Filter: buildBaseFilter(req.Filter),
GroupBy: mergeGroupBy(dualSemconvGroupByKeys["server"], req.GroupBy),
Filter: buildBaseFilter(req.Filter, availability),
GroupBy: mergeGroupBy(getAvailableServerGroupBy(availability), req.GroupBy),
},
}
}
@@ -457,7 +626,7 @@ func buildErrorRateFormula() qbtypes.QueryEnvelope {
}
}
func buildEndpointsInfoQuery(req *thirdpartyapitypes.ThirdPartyApiRequest) qbtypes.QueryEnvelope {
func buildEndpointsInfoQuery(req *thirdpartyapitypes.ThirdPartyApiRequest, availability *ColumnAvailability) qbtypes.QueryEnvelope {
return qbtypes.QueryEnvelope{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
@@ -467,13 +636,13 @@ func buildEndpointsInfoQuery(req *thirdpartyapitypes.ThirdPartyApiRequest) qbtyp
Aggregations: []qbtypes.TraceAggregation{
{Expression: "rate(http.url)"},
},
Filter: buildBaseFilter(req.Filter),
GroupBy: mergeGroupBy(dualSemconvGroupByKeys["url"], req.GroupBy),
Filter: buildBaseFilter(req.Filter, availability),
GroupBy: mergeGroupBy(getAvailableURLGroupBy(availability), req.GroupBy),
},
}
}
func buildP99InfoQuery(req *thirdpartyapitypes.ThirdPartyApiRequest) qbtypes.QueryEnvelope {
func buildP99InfoQuery(req *thirdpartyapitypes.ThirdPartyApiRequest, availability *ColumnAvailability) qbtypes.QueryEnvelope {
return qbtypes.QueryEnvelope{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
@@ -483,13 +652,13 @@ func buildP99InfoQuery(req *thirdpartyapitypes.ThirdPartyApiRequest) qbtypes.Que
Aggregations: []qbtypes.TraceAggregation{
{Expression: "p99(duration_nano)"},
},
Filter: buildBaseFilter(req.Filter),
Filter: buildBaseFilter(req.Filter, availability),
GroupBy: req.GroupBy,
},
}
}
func buildErrorRateInfoQuery(req *thirdpartyapitypes.ThirdPartyApiRequest) qbtypes.QueryEnvelope {
func buildErrorRateInfoQuery(req *thirdpartyapitypes.ThirdPartyApiRequest, availability *ColumnAvailability) qbtypes.QueryEnvelope {
return qbtypes.QueryEnvelope{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
@@ -499,13 +668,13 @@ func buildErrorRateInfoQuery(req *thirdpartyapitypes.ThirdPartyApiRequest) qbtyp
Aggregations: []qbtypes.TraceAggregation{
{Expression: "rate()"},
},
Filter: buildBaseFilter(req.Filter),
Filter: buildBaseFilter(req.Filter, availability),
GroupBy: req.GroupBy,
},
}
}
func buildLastSeenInfoQuery(req *thirdpartyapitypes.ThirdPartyApiRequest) qbtypes.QueryEnvelope {
func buildLastSeenInfoQuery(req *thirdpartyapitypes.ThirdPartyApiRequest, availability *ColumnAvailability) qbtypes.QueryEnvelope {
return qbtypes.QueryEnvelope{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
@@ -515,15 +684,24 @@ func buildLastSeenInfoQuery(req *thirdpartyapitypes.ThirdPartyApiRequest) qbtype
Aggregations: []qbtypes.TraceAggregation{
{Expression: "max(timestamp)"},
},
Filter: buildBaseFilter(req.Filter),
Filter: buildBaseFilter(req.Filter, availability),
GroupBy: req.GroupBy,
},
}
}
func buildBaseFilter(additionalFilter *qbtypes.Filter) *qbtypes.Filter {
baseExpression := fmt.Sprintf("(%s EXISTS OR %s EXISTS) AND kind_string = 'Client'",
urlPathKeyLegacy, urlPathKey)
func buildBaseFilter(additionalFilter *qbtypes.Filter, availability *ColumnAvailability) *qbtypes.Filter {
var urlExistsExpression string
if availability.URLFull && availability.HttpURL {
urlExistsExpression = fmt.Sprintf("(%s EXISTS OR %s EXISTS)", urlPathKeyLegacy, urlPathKey)
} else if availability.URLFull {
urlExistsExpression = fmt.Sprintf("%s EXISTS", urlPathKey)
} else {
urlExistsExpression = fmt.Sprintf("%s EXISTS", urlPathKeyLegacy)
}
baseExpression := fmt.Sprintf("(%s) AND kind_string = 'Client'", urlExistsExpression)
if additionalFilter != nil && additionalFilter.Expression != "" {
if containsKindStringOverride(additionalFilter.Expression) {
@@ -535,9 +713,18 @@ func buildBaseFilter(additionalFilter *qbtypes.Filter) *qbtypes.Filter {
return &qbtypes.Filter{Expression: baseExpression}
}
func buildErrorFilter(additionalFilter *qbtypes.Filter) *qbtypes.Filter {
errorExpression := fmt.Sprintf("has_error = true AND (%s EXISTS OR %s EXISTS) AND kind_string = 'Client'",
urlPathKeyLegacy, urlPathKey)
func buildErrorFilter(additionalFilter *qbtypes.Filter, availability *ColumnAvailability) *qbtypes.Filter {
var urlExistsExpression string
if availability.URLFull && availability.HttpURL {
urlExistsExpression = fmt.Sprintf("(%s EXISTS OR %s EXISTS)", urlPathKeyLegacy, urlPathKey)
} else if availability.URLFull {
urlExistsExpression = fmt.Sprintf("%s EXISTS", urlPathKey)
} else {
urlExistsExpression = fmt.Sprintf("%s EXISTS", urlPathKeyLegacy)
}
errorExpression := fmt.Sprintf("has_error = true AND (%s) AND kind_string = 'Client'", urlExistsExpression)
if additionalFilter != nil && additionalFilter.Expression != "" {
if containsKindStringOverride(additionalFilter.Expression) {

View File

@@ -1640,6 +1640,12 @@ func (r *ClickHouseReader) SetTTLV2(ctx context.Context, orgID string, params *m
return nil, err
}
// Calculate cold storage duration
coldStorageDuration := -1
if len(params.ColdStorageVolume) > 0 && params.ToColdStorageDuration > 0 {
coldStorageDuration = int(params.ToColdStorageDuration) // Already in days
}
tableNames := []string{
r.logsDB + "." + r.logsLocalTableV2,
r.logsDB + "." + r.logsResourceLocalTableV2,
@@ -1655,25 +1661,47 @@ func (r *ClickHouseReader) SetTTLV2(ctx context.Context, orgID string, params *m
}
}
// Build multiIf expressions for both tables
multiIfExpr := r.buildMultiIfExpression(params.TTLConditions, params.DefaultTTLDays, false)
resourceMultiIfExpr := r.buildMultiIfExpression(params.TTLConditions, params.DefaultTTLDays, true)
ttlPayload := map[string]string{
tableNames[0]: fmt.Sprintf(`ALTER TABLE %s ON CLUSTER %s MODIFY COLUMN _retention_days UInt16 DEFAULT %s`,
ttlPayload := make(map[string][]string)
queries := []string{
fmt.Sprintf(`ALTER TABLE %s ON CLUSTER %s MODIFY COLUMN _retention_days UInt16 DEFAULT %s`,
tableNames[0], r.cluster, multiIfExpr),
tableNames[1]: fmt.Sprintf(`ALTER TABLE %s ON CLUSTER %s MODIFY COLUMN _retention_days UInt16 DEFAULT %s`,
}
if len(params.ColdStorageVolume) > 0 && coldStorageDuration > 0 {
queries = append(queries, fmt.Sprintf(`ALTER TABLE %s ON CLUSTER %s MODIFY COLUMN _retention_days_cold UInt16 DEFAULT %d`,
tableNames[0], r.cluster, coldStorageDuration))
queries = append(queries, fmt.Sprintf(`ALTER TABLE %s ON CLUSTER %s MODIFY TTL toDateTime(timestamp / 1000000000) + toIntervalDay(_retention_days) DELETE, toDateTime(timestamp / 1000000000) + toIntervalDay(_retention_days_cold) TO VOLUME '%s' SETTINGS materialize_ttl_after_modify=0`,
tableNames[0], r.cluster, params.ColdStorageVolume))
}
ttlPayload[tableNames[0]] = queries
resourceQueries := []string{
fmt.Sprintf(`ALTER TABLE %s ON CLUSTER %s MODIFY COLUMN _retention_days UInt16 DEFAULT %s`,
tableNames[1], r.cluster, resourceMultiIfExpr),
}
if len(params.ColdStorageVolume) > 0 && coldStorageDuration > 0 {
resourceQueries = append(resourceQueries, fmt.Sprintf(`ALTER TABLE %s ON CLUSTER %s MODIFY COLUMN _retention_days_cold UInt16 DEFAULT %d`,
tableNames[1], r.cluster, coldStorageDuration))
resourceQueries = append(resourceQueries, fmt.Sprintf(`ALTER TABLE %s ON CLUSTER %s MODIFY TTL toDateTime(seen_at_ts_bucket_start) + toIntervalSecond(1800) + toIntervalDay(_retention_days) DELETE, toDateTime(seen_at_ts_bucket_start) + toIntervalSecond(1800) + toIntervalDay(_retention_days_cold) TO VOLUME '%s' SETTINGS materialize_ttl_after_modify=0`,
tableNames[1], r.cluster, params.ColdStorageVolume))
}
ttlPayload[tableNames[1]] = resourceQueries
ttlConditionsJSON, err := json.Marshal(params.TTLConditions)
if err != nil {
return nil, errorsV2.Wrapf(err, errorsV2.TypeInternal, errorsV2.CodeInternal, "error marshalling TTL condition")
}
// Execute the TTL modifications synchronously
for tableName, query := range ttlPayload {
// Store the operation in the database
for tableName, queries := range ttlPayload {
customTTL := types.TTLSetting{
Identifiable: types.Identifiable{
ID: valuer.GenerateUUID(),
@@ -1682,12 +1710,13 @@ func (r *ClickHouseReader) SetTTLV2(ctx context.Context, orgID string, params *m
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
TransactionID: uuid,
TableName: tableName,
TTL: params.DefaultTTLDays,
Condition: string(ttlConditionsJSON),
Status: constants.StatusPending,
OrgID: orgID,
TransactionID: uuid,
TableName: tableName,
TTL: params.DefaultTTLDays,
Condition: string(ttlConditionsJSON),
Status: constants.StatusPending,
ColdStorageTTL: coldStorageDuration,
OrgID: orgID,
}
// Insert TTL setting record
@@ -1697,19 +1726,24 @@ func (r *ClickHouseReader) SetTTLV2(ctx context.Context, orgID string, params *m
return nil, errorsV2.Wrapf(dbErr, errorsV2.TypeInternal, errorsV2.CodeInternal, "error inserting TTL settings")
}
zap.L().Debug("Executing custom retention TTL request: ", zap.String("request", query))
// Execute the ALTER TABLE query
if err := r.db.Exec(ctx, query); err != nil {
zap.L().Error("error while setting custom retention ttl", zap.Error(err))
// Update status to failed
r.updateCustomRetentionTTLStatus(ctx, orgID, tableName, constants.StatusFailed)
return nil, errorsV2.Wrapf(err, errorsV2.TypeInternal, errorsV2.CodeInternal, "error setting custom retention TTL for table %s", tableName)
if len(params.ColdStorageVolume) > 0 && coldStorageDuration > 0 {
err := r.setColdStorage(ctx, tableName, params.ColdStorageVolume)
if err != nil {
zap.L().Error("error in setting cold storage", zap.Error(err))
r.updateCustomRetentionTTLStatus(ctx, orgID, tableName, constants.StatusFailed)
return nil, errorsV2.Wrapf(err.Err, errorsV2.TypeInternal, errorsV2.CodeInternal, "error setting cold storage for table %s", tableName)
}
}
for i, query := range queries {
zap.L().Debug("Executing custom retention TTL request: ", zap.String("request", query), zap.Int("step", i+1))
if err := r.db.Exec(ctx, query); err != nil {
zap.L().Error("error while setting custom retention ttl", zap.Error(err))
r.updateCustomRetentionTTLStatus(ctx, orgID, tableName, constants.StatusFailed)
return nil, errorsV2.Wrapf(err, errorsV2.TypeInternal, errorsV2.CodeInternal, "error setting custom retention TTL for table %s, query: %s", tableName, query)
}
}
// Update status to success
r.updateCustomRetentionTTLStatus(ctx, orgID, tableName, constants.StatusSuccess)
}
@@ -1841,6 +1875,7 @@ func (r *ClickHouseReader) GetCustomRetentionTTL(ctx context.Context, orgID stri
response.DefaultTTLDays = customTTL.TTL
response.TTLConditions = ttlConditions
response.Status = customTTL.Status
response.ColdStorageTTLDays = customTTL.ColdStorageTTL
} else {
// V1 - Traditional TTL

View File

@@ -7,6 +7,7 @@ import (
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagerserver"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager/nfmanagertest"
"github.com/SigNoz/signoz/pkg/alertmanager/signozalertmanager"
"github.com/SigNoz/signoz/pkg/analytics/analyticstest"
"github.com/SigNoz/signoz/pkg/emailing/emailingtest"
@@ -35,7 +36,9 @@ func TestRegenerateConnectionUrlWithUpdatedConfig(t *testing.T) {
sharder, err := noopsharder.New(context.TODO(), providerSettings, sharder.Config{})
require.NoError(err)
orgGetter := implorganization.NewGetter(implorganization.NewStore(sqlStore), sharder)
alertmanager, err := signozalertmanager.New(context.TODO(), providerSettings, alertmanager.Config{Provider: "signoz", Signoz: alertmanager.Signoz{PollInterval: 10 * time.Second, Config: alertmanagerserver.NewConfig()}}, sqlStore, orgGetter)
notificationManager := nfmanagertest.NewMock()
require.NoError(err)
alertmanager, err := signozalertmanager.New(context.TODO(), providerSettings, alertmanager.Config{Provider: "signoz", Signoz: alertmanager.Signoz{PollInterval: 10 * time.Second, Config: alertmanagerserver.NewConfig()}}, sqlStore, orgGetter, notificationManager)
require.NoError(err)
jwt := authtypes.NewJWT("", 1*time.Hour, 1*time.Hour)
emailing := emailingtest.New()
@@ -92,7 +95,9 @@ func TestAgentCheckIns(t *testing.T) {
sharder, err := noopsharder.New(context.TODO(), providerSettings, sharder.Config{})
require.NoError(err)
orgGetter := implorganization.NewGetter(implorganization.NewStore(sqlStore), sharder)
alertmanager, err := signozalertmanager.New(context.TODO(), providerSettings, alertmanager.Config{Provider: "signoz", Signoz: alertmanager.Signoz{PollInterval: 10 * time.Second, Config: alertmanagerserver.NewConfig()}}, sqlStore, orgGetter)
notificationManager := nfmanagertest.NewMock()
require.NoError(err)
alertmanager, err := signozalertmanager.New(context.TODO(), providerSettings, alertmanager.Config{Provider: "signoz", Signoz: alertmanager.Signoz{PollInterval: 10 * time.Second, Config: alertmanagerserver.NewConfig()}}, sqlStore, orgGetter, notificationManager)
require.NoError(err)
jwt := authtypes.NewJWT("", 1*time.Hour, 1*time.Hour)
emailing := emailingtest.New()
@@ -188,7 +193,9 @@ func TestCantDisconnectNonExistentAccount(t *testing.T) {
sharder, err := noopsharder.New(context.TODO(), providerSettings, sharder.Config{})
require.NoError(err)
orgGetter := implorganization.NewGetter(implorganization.NewStore(sqlStore), sharder)
alertmanager, err := signozalertmanager.New(context.TODO(), providerSettings, alertmanager.Config{Provider: "signoz", Signoz: alertmanager.Signoz{PollInterval: 10 * time.Second, Config: alertmanagerserver.NewConfig()}}, sqlStore, orgGetter)
notificationManager := nfmanagertest.NewMock()
require.NoError(err)
alertmanager, err := signozalertmanager.New(context.TODO(), providerSettings, alertmanager.Config{Provider: "signoz", Signoz: alertmanager.Signoz{PollInterval: 10 * time.Second, Config: alertmanagerserver.NewConfig()}}, sqlStore, orgGetter, notificationManager)
require.NoError(err)
jwt := authtypes.NewJWT("", 1*time.Hour, 1*time.Hour)
emailing := emailingtest.New()
@@ -216,7 +223,9 @@ func TestConfigureService(t *testing.T) {
sharder, err := noopsharder.New(context.TODO(), providerSettings, sharder.Config{})
require.NoError(err)
orgGetter := implorganization.NewGetter(implorganization.NewStore(sqlStore), sharder)
alertmanager, err := signozalertmanager.New(context.TODO(), providerSettings, alertmanager.Config{Provider: "signoz", Signoz: alertmanager.Signoz{PollInterval: 10 * time.Second, Config: alertmanagerserver.NewConfig()}}, sqlStore, orgGetter)
notificationManager := nfmanagertest.NewMock()
require.NoError(err)
alertmanager, err := signozalertmanager.New(context.TODO(), providerSettings, alertmanager.Config{Provider: "signoz", Signoz: alertmanager.Signoz{PollInterval: 10 * time.Second, Config: alertmanagerserver.NewConfig()}}, sqlStore, orgGetter, notificationManager)
require.NoError(err)
jwt := authtypes.NewJWT("", 1*time.Hour, 1*time.Hour)
emailing := emailingtest.New()

View File

@@ -5045,8 +5045,10 @@ func (aH *APIHandler) getDomainList(w http.ResponseWriter, r *http.Request) {
return
}
availability := thirdpartyapi.CheckColumnAvailability(r.Context(), aH.Signoz.Querier, orgID, thirdPartyQueryRequest.Start, thirdPartyQueryRequest.End)
// Build the v5 query range request for domain listing
queryRangeRequest, err := thirdpartyapi.BuildDomainList(thirdPartyQueryRequest)
queryRangeRequest, err := thirdpartyapi.BuildDomainList(thirdPartyQueryRequest, availability)
if err != nil {
zap.L().Error("Failed to build domain list query", zap.Error(err))
apiErrObj := errorsV2.New(errorsV2.TypeInvalidInput, errorsV2.CodeInvalidInput, err.Error())
@@ -5065,6 +5067,7 @@ func (aH *APIHandler) getDomainList(w http.ResponseWriter, r *http.Request) {
result = thirdpartyapi.MergeSemconvColumns(result)
result = thirdpartyapi.FilterIntermediateColumns(result)
result = thirdpartyapi.FilterNullValues(result)
// Filter IP addresses if ShowIp is false
var finalResult = result
@@ -5102,8 +5105,16 @@ func (aH *APIHandler) getDomainInfo(w http.ResponseWriter, r *http.Request) {
return
}
// Check column availability for semconv attributes
availability := thirdpartyapi.CheckColumnAvailability(r.Context(), aH.Signoz.Querier, orgID, thirdPartyQueryRequest.Start, thirdPartyQueryRequest.End)
zap.L().Debug("Column availability detected for domain info",
zap.Bool("http_url", availability.HttpURL),
zap.Bool("url_full", availability.URLFull),
zap.Bool("net_peer_name", availability.NetPeerName),
zap.Bool("server_address", availability.ServerAddress))
// Build the v5 query range request for domain info
queryRangeRequest, err := thirdpartyapi.BuildDomainInfo(thirdPartyQueryRequest)
queryRangeRequest, err := thirdpartyapi.BuildDomainInfo(thirdPartyQueryRequest, availability)
if err != nil {
zap.L().Error("Failed to build domain info query", zap.Error(err))
apiErrObj := errorsV2.New(errorsV2.TypeInvalidInput, errorsV2.CodeInvalidInput, err.Error())
@@ -5122,6 +5133,7 @@ func (aH *APIHandler) getDomainInfo(w http.ResponseWriter, r *http.Request) {
result = thirdpartyapi.MergeSemconvColumns(result)
result = thirdpartyapi.FilterIntermediateColumns(result)
result = thirdpartyapi.FilterNullValues(result)
// Filter IP addresses if ShowIp is false
var finalResult *qbtypes.QueryRangeResponse = result

View File

@@ -7,6 +7,7 @@ import (
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagerserver"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager/nfmanagertest"
"github.com/SigNoz/signoz/pkg/alertmanager/signozalertmanager"
"github.com/SigNoz/signoz/pkg/analytics/analyticstest"
"github.com/SigNoz/signoz/pkg/emailing/emailingtest"
@@ -29,7 +30,8 @@ func TestIntegrationLifecycle(t *testing.T) {
providerSettings := instrumentationtest.New().ToProviderSettings()
sharder, _ := noopsharder.New(context.TODO(), providerSettings, sharder.Config{})
orgGetter := implorganization.NewGetter(implorganization.NewStore(store), sharder)
alertmanager, _ := signozalertmanager.New(context.TODO(), providerSettings, alertmanager.Config{Provider: "signoz", Signoz: alertmanager.Signoz{PollInterval: 10 * time.Second, Config: alertmanagerserver.NewConfig()}}, store, orgGetter)
notificationManager := nfmanagertest.NewMock()
alertmanager, _ := signozalertmanager.New(context.TODO(), providerSettings, alertmanager.Config{Provider: "signoz", Signoz: alertmanager.Signoz{PollInterval: 10 * time.Second, Config: alertmanagerserver.NewConfig()}}, store, orgGetter, notificationManager)
jwt := authtypes.NewJWT("", 1*time.Hour, 1*time.Hour)
emailing := emailingtest.New()
analytics := analyticstest.New()

View File

@@ -433,8 +433,10 @@ type GetCustomRetentionTTLResponse struct {
ExpectedLogsMoveTime int `json:"expected_logs_move_ttl_duration_hrs,omitempty"`
// V2 fields
DefaultTTLDays int `json:"default_ttl_days,omitempty"`
TTLConditions []CustomRetentionRule `json:"ttl_conditions,omitempty"`
DefaultTTLDays int `json:"default_ttl_days,omitempty"`
TTLConditions []CustomRetentionRule `json:"ttl_conditions,omitempty"`
ColdStorageVolume string `json:"cold_storage_volume,omitempty"`
ColdStorageTTLDays int `json:"cold_storage_ttl_days,omitempty"`
}
type CustomRetentionTTLResponse struct {

View File

@@ -226,6 +226,7 @@ func NewManager(o *ManagerOptions) (*Manager, error) {
sqlstore: o.SqlStore,
}
zap.L().Debug("Manager created successfully with NotificationGroup")
return m, nil
}
@@ -278,7 +279,14 @@ func (m *Manager) initiate(ctx context.Context) error {
loadErrors = append(loadErrors, err)
continue
}
if parsedRule.NotificationSettings != nil {
config := parsedRule.NotificationSettings.GetAlertManagerNotificationConfig()
err = m.alertmanager.SetNotificationConfig(ctx, org.ID, rec.ID.StringValue(), &config)
if err != nil {
loadErrors = append(loadErrors, err)
zap.L().Info("failed to set rule notification config", zap.String("ruleId", rec.ID.StringValue()))
}
}
if !parsedRule.Disabled {
err := m.addTask(ctx, org.ID, &parsedRule, taskName)
if err != nil {
@@ -360,17 +368,22 @@ func (m *Manager) EditRule(ctx context.Context, ruleStr string, id valuer.UUID)
} else {
preferredChannels = parsedRule.PreferredChannels
}
err = cfg.UpdateRuleIDMatcher(id.StringValue(), preferredChannels)
if err != nil {
return err
}
if parsedRule.NotificationSettings != nil {
config := parsedRule.NotificationSettings.GetAlertManagerNotificationConfig()
err = m.alertmanager.SetNotificationConfig(ctx, orgID, existingRule.ID.StringValue(), &config)
if err != nil {
return err
}
}
err = m.alertmanager.SetConfig(ctx, cfg)
if err != nil {
return err
}
err = m.syncRuleStateWithTask(ctx, orgID, prepareTaskName(existingRule.ID.StringValue()), &parsedRule)
if err != nil {
return err
@@ -453,6 +466,11 @@ func (m *Manager) DeleteRule(ctx context.Context, idStr string) error {
return err
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
return err
}
return m.ruleStore.DeleteRule(ctx, id, func(ctx context.Context) error {
cfg, err := m.alertmanager.GetConfig(ctx, claims.OrgID)
if err != nil {
@@ -469,6 +487,8 @@ func (m *Manager) DeleteRule(ctx context.Context, idStr string) error {
return err
}
err = m.alertmanager.DeleteNotificationConfig(ctx, orgID, id.String())
taskName := prepareTaskName(id.StringValue())
m.deleteTask(taskName)
@@ -547,6 +567,14 @@ func (m *Manager) CreateRule(ctx context.Context, ruleStr string) (*ruletypes.Ge
preferredChannels = parsedRule.PreferredChannels
}
if parsedRule.NotificationSettings != nil {
config := parsedRule.NotificationSettings.GetAlertManagerNotificationConfig()
err = m.alertmanager.SetNotificationConfig(ctx, orgID, storedRule.ID.StringValue(), &config)
if err != nil {
return err
}
}
err = cfg.CreateRuleIDMatcher(id.StringValue(), preferredChannels)
if err != nil {
return err
@@ -558,7 +586,7 @@ func (m *Manager) CreateRule(ctx context.Context, ruleStr string) (*ruletypes.Ge
}
taskName := prepareTaskName(id.StringValue())
if err := m.addTask(ctx, orgID, &parsedRule, taskName); err != nil {
if err = m.addTask(ctx, orgID, &parsedRule, taskName); err != nil {
return err
}
@@ -732,13 +760,12 @@ func (m *Manager) prepareTestNotifyFunc() NotifyFunc {
alert := alerts[0]
generatorURL := alert.GeneratorURL
a := &alertmanagertypes.PostableAlert{
Annotations: alert.Annotations.Map(),
StartsAt: strfmt.DateTime(alert.FiredAt),
Alert: alertmanagertypes.AlertModel{
Labels: alert.Labels.Map(),
GeneratorURL: strfmt.URI(generatorURL),
},
a := &alertmanagertypes.PostableAlert{}
a.Annotations = alert.Annotations.Map()
a.StartsAt = strfmt.DateTime(alert.FiredAt)
a.Alert = alertmanagertypes.AlertModel{
Labels: alert.Labels.Map(),
GeneratorURL: strfmt.URI(generatorURL),
}
if !alert.ResolvedAt.IsZero() {
a.EndsAt = strfmt.DateTime(alert.ResolvedAt)

View File

@@ -5,6 +5,7 @@ import (
"testing"
"time"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager/nfmanagertest"
"github.com/stretchr/testify/assert"
"go.uber.org/zap"
@@ -265,7 +266,8 @@ func setupTestManager(t *testing.T) (*Manager, *rulestoretest.MockSQLRuleStore,
t.Fatalf("Failed to create noop sharder: %v", err)
}
orgGetter := implorganization.NewGetter(implorganization.NewStore(testDB), noopSharder)
alertManager, err := signozalertmanager.New(context.TODO(), settings, alertmanager.Config{Provider: "signoz", Signoz: alertmanager.Signoz{PollInterval: 10 * time.Second, Config: alertmanagerserver.NewConfig()}}, testDB, orgGetter)
notificationManager := nfmanagertest.NewMock()
alertManager, err := signozalertmanager.New(context.TODO(), settings, alertmanager.Config{Provider: "signoz", Signoz: alertmanager.Signoz{PollInterval: 10 * time.Second, Config: alertmanagerserver.NewConfig()}}, testDB, orgGetter, notificationManager)
if err != nil {
t.Fatalf("Failed to create alert manager: %v", err)
}

View File

@@ -654,6 +654,7 @@ func (r *ThresholdRule) Eval(ctx context.Context, ts time.Time) (interface{}, er
}
if smpl.IsMissing {
lb.Set(labels.AlertNameLabel, "[No data] "+r.Name())
lb.Set(labels.NoDataLabel, "true")
}
// Links with timestamps should go in annotations since labels

View File

@@ -13,6 +13,7 @@ import (
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagerserver"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager/nfmanagertest"
"github.com/SigNoz/signoz/pkg/alertmanager/signozalertmanager"
"github.com/SigNoz/signoz/pkg/analytics/analyticstest"
"github.com/SigNoz/signoz/pkg/emailing/emailingtest"
@@ -312,7 +313,9 @@ func NewFilterSuggestionsTestBed(t *testing.T) *FilterSuggestionsTestBed {
sharder, err := noopsharder.New(context.TODO(), providerSettings, sharder.Config{})
require.NoError(t, err)
orgGetter := implorganization.NewGetter(implorganization.NewStore(testDB), sharder)
alertmanager, err := signozalertmanager.New(context.TODO(), providerSettings, alertmanager.Config{Signoz: alertmanager.Signoz{PollInterval: 10 * time.Second, Config: alertmanagerserver.NewConfig()}}, testDB, orgGetter)
notificationManager := nfmanagertest.NewMock()
require.NoError(t, err)
alertmanager, err := signozalertmanager.New(context.TODO(), providerSettings, alertmanager.Config{Signoz: alertmanager.Signoz{PollInterval: 10 * time.Second, Config: alertmanagerserver.NewConfig()}}, testDB, orgGetter, notificationManager)
require.NoError(t, err)
jwt := authtypes.NewJWT("", 1*time.Hour, 1*time.Hour)
emailing := emailingtest.New()

View File

@@ -14,6 +14,7 @@ import (
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagerserver"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager/nfmanagertest"
"github.com/SigNoz/signoz/pkg/alertmanager/signozalertmanager"
"github.com/SigNoz/signoz/pkg/analytics/analyticstest"
"github.com/SigNoz/signoz/pkg/emailing/emailingtest"
@@ -492,7 +493,9 @@ func NewTestbedWithoutOpamp(t *testing.T, sqlStore sqlstore.SQLStore) *LogPipeli
sharder, err := noopsharder.New(context.Background(), providerSettings, sharder.Config{})
require.NoError(t, err)
orgGetter := implorganization.NewGetter(implorganization.NewStore(sqlStore), sharder)
alertmanager, err := signozalertmanager.New(context.Background(), providerSettings, alertmanager.Config{Signoz: alertmanager.Signoz{PollInterval: 10 * time.Second, Config: alertmanagerserver.NewConfig()}}, sqlStore, orgGetter)
notificationManager := nfmanagertest.NewMock()
require.NoError(t, err)
alertmanager, err := signozalertmanager.New(context.Background(), providerSettings, alertmanager.Config{Signoz: alertmanager.Signoz{PollInterval: 10 * time.Second, Config: alertmanagerserver.NewConfig()}}, sqlStore, orgGetter, notificationManager)
require.NoError(t, err)
jwt := authtypes.NewJWT("", 1*time.Hour, 1*time.Hour)
emailing := emailingtest.New()

View File

@@ -11,6 +11,7 @@ import (
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagerserver"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager/nfmanagertest"
"github.com/SigNoz/signoz/pkg/alertmanager/signozalertmanager"
"github.com/SigNoz/signoz/pkg/analytics/analyticstest"
"github.com/SigNoz/signoz/pkg/emailing/emailingtest"
@@ -373,7 +374,9 @@ func NewCloudIntegrationsTestBed(t *testing.T, testDB sqlstore.SQLStore) *CloudI
sharder, err := noopsharder.New(context.TODO(), providerSettings, sharder.Config{})
require.NoError(t, err)
orgGetter := implorganization.NewGetter(implorganization.NewStore(testDB), sharder)
alertmanager, err := signozalertmanager.New(context.TODO(), providerSettings, alertmanager.Config{Signoz: alertmanager.Signoz{PollInterval: 10 * time.Second, Config: alertmanagerserver.NewConfig()}}, testDB, orgGetter)
nfmanager := nfmanagertest.NewMock()
require.NoError(t, err)
alertmanager, err := signozalertmanager.New(context.TODO(), providerSettings, alertmanager.Config{Signoz: alertmanager.Signoz{PollInterval: 10 * time.Second, Config: alertmanagerserver.NewConfig()}}, testDB, orgGetter, nfmanager)
require.NoError(t, err)
jwt := authtypes.NewJWT("", 1*time.Hour, 1*time.Hour)
emailing := emailingtest.New()

View File

@@ -11,6 +11,7 @@ import (
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagerserver"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager/nfmanagertest"
"github.com/SigNoz/signoz/pkg/alertmanager/signozalertmanager"
"github.com/SigNoz/signoz/pkg/analytics/analyticstest"
"github.com/SigNoz/signoz/pkg/emailing/emailingtest"
@@ -588,7 +589,11 @@ func NewIntegrationsTestBed(t *testing.T, testDB sqlstore.SQLStore) *Integration
sharder, err := noopsharder.New(context.TODO(), providerSettings, sharder.Config{})
require.NoError(t, err)
orgGetter := implorganization.NewGetter(implorganization.NewStore(testDB), sharder)
alertmanager, err := signozalertmanager.New(context.TODO(), providerSettings, alertmanager.Config{Signoz: alertmanager.Signoz{PollInterval: 10 * time.Second, Config: alertmanagerserver.NewConfig()}}, testDB, orgGetter)
nfManager := nfmanagertest.NewMock()
if err != nil {
t.Fatal(err)
}
alertmanager, err := signozalertmanager.New(context.TODO(), providerSettings, alertmanager.Config{Signoz: alertmanager.Signoz{PollInterval: 10 * time.Second, Config: alertmanagerserver.NewConfig()}}, testDB, orgGetter, nfManager)
require.NoError(t, err)
jwt := authtypes.NewJWT("", 1*time.Hour, 1*time.Hour)
emailing := emailingtest.New()

View File

@@ -17,6 +17,7 @@ const (
MetricNameLabel = "__name__"
TemporalityLabel = "__temporality__"
AlertNameLabel = "alertname"
NoDataLabel = "nodata"
// AlertStateLabel is the label name indicating the state of an alert.
AlertStateLabel = "alertstate"

View File

@@ -8,6 +8,7 @@ import (
"github.com/DATA-DOG/go-sqlmock"
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager/nfmanagertest"
"github.com/SigNoz/signoz/pkg/alertmanager/signozalertmanager"
"github.com/SigNoz/signoz/pkg/emailing/emailingtest"
"github.com/SigNoz/signoz/pkg/factory/factorytest"
@@ -29,7 +30,9 @@ func TestNewHandlers(t *testing.T) {
sharder, err := noopsharder.New(context.TODO(), providerSettings, sharder.Config{})
require.NoError(t, err)
orgGetter := implorganization.NewGetter(implorganization.NewStore(sqlstore), sharder)
alertmanager, err := signozalertmanager.New(context.TODO(), providerSettings, alertmanager.Config{}, sqlstore, orgGetter)
notificationManager := nfmanagertest.NewMock()
require.NoError(t, err)
alertmanager, err := signozalertmanager.New(context.TODO(), providerSettings, alertmanager.Config{}, sqlstore, orgGetter, notificationManager)
require.NoError(t, err)
jwt := authtypes.NewJWT("", 1*time.Hour, 1*time.Hour)
emailing := emailingtest.New()

View File

@@ -8,6 +8,7 @@ import (
"github.com/DATA-DOG/go-sqlmock"
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager/nfmanagertest"
"github.com/SigNoz/signoz/pkg/alertmanager/signozalertmanager"
"github.com/SigNoz/signoz/pkg/emailing/emailingtest"
"github.com/SigNoz/signoz/pkg/factory/factorytest"
@@ -29,7 +30,9 @@ func TestNewModules(t *testing.T) {
sharder, err := noopsharder.New(context.TODO(), providerSettings, sharder.Config{})
require.NoError(t, err)
orgGetter := implorganization.NewGetter(implorganization.NewStore(sqlstore), sharder)
alertmanager, err := signozalertmanager.New(context.TODO(), providerSettings, alertmanager.Config{}, sqlstore, orgGetter)
notificationManager := nfmanagertest.NewMock()
require.NoError(t, err)
alertmanager, err := signozalertmanager.New(context.TODO(), providerSettings, alertmanager.Config{}, sqlstore, orgGetter, notificationManager)
require.NoError(t, err)
jwt := authtypes.NewJWT("", 1*time.Hour, 1*time.Hour)
emailing := emailingtest.New()

View File

@@ -2,6 +2,8 @@ package signoz
import (
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager/rulebasednotification"
"github.com/SigNoz/signoz/pkg/alertmanager/signozalertmanager"
"github.com/SigNoz/signoz/pkg/analytics"
"github.com/SigNoz/signoz/pkg/analytics/noopanalytics"
@@ -153,9 +155,15 @@ func NewPrometheusProviderFactories(telemetryStore telemetrystore.TelemetryStore
)
}
func NewAlertmanagerProviderFactories(sqlstore sqlstore.SQLStore, orgGetter organization.Getter) factory.NamedMap[factory.ProviderFactory[alertmanager.Alertmanager, alertmanager.Config]] {
func NewNotificationManagerProviderFactories() factory.NamedMap[factory.ProviderFactory[nfmanager.NotificationManager, nfmanager.Config]] {
return factory.MustNewNamedMap(
signozalertmanager.NewFactory(sqlstore, orgGetter),
rulebasednotification.NewFactory(),
)
}
func NewAlertmanagerProviderFactories(sqlstore sqlstore.SQLStore, orgGetter organization.Getter, notificationManager nfmanager.NotificationManager) factory.NamedMap[factory.ProviderFactory[alertmanager.Alertmanager, alertmanager.Config]] {
return factory.MustNewNamedMap(
signozalertmanager.NewFactory(sqlstore, orgGetter, notificationManager),
)
}

View File

@@ -4,6 +4,7 @@ import (
"testing"
"github.com/DATA-DOG/go-sqlmock"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager/nfmanagertest"
"github.com/SigNoz/signoz/pkg/analytics"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
"github.com/SigNoz/signoz/pkg/modules/organization/implorganization"
@@ -54,7 +55,8 @@ func TestNewProviderFactories(t *testing.T) {
assert.NotPanics(t, func() {
orgGetter := implorganization.NewGetter(implorganization.NewStore(sqlstoretest.New(sqlstore.Config{Provider: "sqlite"}, sqlmock.QueryMatcherEqual)), nil)
NewAlertmanagerProviderFactories(sqlstoretest.New(sqlstore.Config{Provider: "sqlite"}, sqlmock.QueryMatcherEqual), orgGetter)
notificationManager := nfmanagertest.NewMock()
NewAlertmanagerProviderFactories(sqlstoretest.New(sqlstore.Config{Provider: "sqlite"}, sqlmock.QueryMatcherEqual), orgGetter, notificationManager)
})
assert.NotPanics(t, func() {

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