mirror of
https://github.com/SigNoz/signoz.git
synced 2026-04-23 20:30:31 +01:00
Compare commits
54 Commits
fixes/dril
...
ns/waterfa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe7d7b2a8a | ||
|
|
78f8d938a1 | ||
|
|
03af4ccc47 | ||
|
|
c595506a09 | ||
|
|
21ce7a663a | ||
|
|
e6e2f95ec2 | ||
|
|
afe85c48f9 | ||
|
|
aeadeacc70 | ||
|
|
6996d41b01 | ||
|
|
f62024ad3f | ||
|
|
93f5df9185 | ||
|
|
89b755a6b0 | ||
|
|
ccaf39dbc4 | ||
|
|
df348ba23d | ||
|
|
384557e5e3 | ||
|
|
264898486f | ||
|
|
bc41ac47eb | ||
|
|
726613f561 | ||
|
|
8bec044909 | ||
|
|
0faeedf5a2 | ||
|
|
6fe6bad879 | ||
|
|
c49a24d57d | ||
|
|
e98e50ae0f | ||
|
|
903acda4a5 | ||
|
|
4e3e2424bd | ||
|
|
76a74bc914 | ||
|
|
ac10be2eb2 | ||
|
|
c9abc2cb30 | ||
|
|
01824b0b62 | ||
|
|
d1b378992d | ||
|
|
52ca921d2a | ||
|
|
42f12dfef3 | ||
|
|
f2a694447e | ||
|
|
2e7dfa739f | ||
|
|
0aa73580a3 | ||
|
|
2ff1a43bf8 | ||
|
|
c1477c78be | ||
|
|
9807dd5295 | ||
|
|
2c59eeff26 | ||
|
|
8ccfb4efef | ||
|
|
87d18160e8 | ||
|
|
bfa7ee96da | ||
|
|
5e3eb66d3a | ||
|
|
3d8cdf18bd | ||
|
|
cb4e501047 | ||
|
|
cb8b2137ba | ||
|
|
998315a255 | ||
|
|
250657e46b | ||
|
|
795ae9ab18 | ||
|
|
6a9ea8d9f8 | ||
|
|
2723e18023 | ||
|
|
6e89d5f6eb | ||
|
|
4c2a815236 | ||
|
|
b1d66b2e5f |
91
.github/workflows/e2eci.yaml
vendored
Normal file
91
.github/workflows/e2eci.yaml
vendored
Normal file
@@ -0,0 +1,91 @@
|
||||
name: e2eci
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- labeled
|
||||
pull_request_target:
|
||||
types:
|
||||
- labeled
|
||||
|
||||
jobs:
|
||||
fmtlint:
|
||||
if: |
|
||||
((github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
|
||||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))) && contains(github.event.pull_request.labels.*.name, 'safe-to-e2e')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
- name: install
|
||||
run: |
|
||||
cd tests/e2e && yarn install --frozen-lockfile
|
||||
- name: fmt
|
||||
run: |
|
||||
cd tests/e2e && yarn fmt:check
|
||||
- name: lint
|
||||
run: |
|
||||
cd tests/e2e && yarn lint
|
||||
test:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
project:
|
||||
- chromium
|
||||
if: |
|
||||
((github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
|
||||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))) && contains(github.event.pull_request.labels.*.name, 'safe-to-e2e')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.13
|
||||
- name: uv
|
||||
uses: astral-sh/setup-uv@v4
|
||||
- name: node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
- name: python-install
|
||||
run: |
|
||||
cd tests && uv sync
|
||||
- name: yarn-install
|
||||
run: |
|
||||
cd tests/e2e && yarn install --frozen-lockfile
|
||||
- name: playwright-browsers
|
||||
run: |
|
||||
cd tests/e2e && yarn playwright install --with-deps ${{ matrix.project }}
|
||||
- name: bring-up-stack
|
||||
run: |
|
||||
cd tests && \
|
||||
uv run pytest \
|
||||
--basetemp=./tmp/ \
|
||||
-vv --reuse --with-web \
|
||||
e2e/bootstrap/setup.py::test_setup
|
||||
- name: playwright-test
|
||||
run: |
|
||||
cd tests/e2e && \
|
||||
yarn playwright test --project=${{ matrix.project }}
|
||||
- name: teardown-stack
|
||||
if: always()
|
||||
run: |
|
||||
cd tests && \
|
||||
uv run pytest \
|
||||
--basetemp=./tmp/ \
|
||||
-vv --teardown \
|
||||
e2e/bootstrap/setup.py::test_teardown
|
||||
- name: upload-artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-artifacts-${{ matrix.project }}
|
||||
path: tests/e2e/artifacts/
|
||||
retention-days: 5
|
||||
23
.github/workflows/integrationci.yaml
vendored
23
.github/workflows/integrationci.yaml
vendored
@@ -25,11 +25,11 @@ jobs:
|
||||
uses: astral-sh/setup-uv@v4
|
||||
- name: install
|
||||
run: |
|
||||
cd tests/integration && uv sync
|
||||
cd tests && uv sync
|
||||
- name: fmt
|
||||
run: |
|
||||
make py-fmt
|
||||
git diff --exit-code -- tests/integration/
|
||||
git diff --exit-code -- tests/
|
||||
- name: lint
|
||||
run: |
|
||||
make py-lint
|
||||
@@ -37,21 +37,21 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
src:
|
||||
- bootstrap
|
||||
- passwordauthn
|
||||
suite:
|
||||
- alerts
|
||||
- callbackauthn
|
||||
- cloudintegrations
|
||||
- dashboard
|
||||
- ingestionkeys
|
||||
- logspipelines
|
||||
- passwordauthn
|
||||
- preference
|
||||
- querier
|
||||
- rawexportdata
|
||||
- role
|
||||
- ttl
|
||||
- alerts
|
||||
- ingestionkeys
|
||||
- rootuser
|
||||
- serviceaccount
|
||||
- ttl
|
||||
sqlstore-provider:
|
||||
- postgres
|
||||
- sqlite
|
||||
@@ -79,8 +79,9 @@ jobs:
|
||||
uses: astral-sh/setup-uv@v4
|
||||
- name: install
|
||||
run: |
|
||||
cd tests/integration && uv sync
|
||||
cd tests && uv sync
|
||||
- name: webdriver
|
||||
if: matrix.suite == 'callbackauthn'
|
||||
run: |
|
||||
wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | sudo apt-key add -
|
||||
echo "deb http://dl.google.com/linux/chrome/deb/ stable main" | sudo tee -a /etc/apt/sources.list.d/google-chrome.list
|
||||
@@ -99,10 +100,10 @@ jobs:
|
||||
google-chrome-stable --version
|
||||
- name: run
|
||||
run: |
|
||||
cd tests/integration && \
|
||||
cd tests && \
|
||||
uv run pytest \
|
||||
--basetemp=./tmp/ \
|
||||
src/${{matrix.src}} \
|
||||
integration/tests/${{matrix.suite}} \
|
||||
--sqlstore-provider ${{matrix.sqlstore-provider}} \
|
||||
--sqlite-mode ${{matrix.sqlite-mode}} \
|
||||
--postgres-version ${{matrix.postgres-version}} \
|
||||
|
||||
62
.github/workflows/run-e2e.yaml
vendored
62
.github/workflows/run-e2e.yaml
vendored
@@ -1,62 +0,0 @@
|
||||
name: e2eci
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
userRole:
|
||||
description: "Role of the user (ADMIN, EDITOR, VIEWER)"
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- ADMIN
|
||||
- EDITOR
|
||||
- VIEWER
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Run Playwright Tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
|
||||
- name: Mask secrets and input
|
||||
run: |
|
||||
echo "::add-mask::${{ secrets.BASE_URL }}"
|
||||
echo "::add-mask::${{ secrets.LOGIN_USERNAME }}"
|
||||
echo "::add-mask::${{ secrets.LOGIN_PASSWORD }}"
|
||||
echo "::add-mask::${{ github.event.inputs.userRole }}"
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: frontend
|
||||
run: |
|
||||
npm install -g yarn
|
||||
yarn
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
working-directory: frontend
|
||||
run: yarn playwright install --with-deps
|
||||
|
||||
- name: Run Playwright Tests
|
||||
working-directory: frontend
|
||||
run: |
|
||||
BASE_URL="${{ secrets.BASE_URL }}" \
|
||||
LOGIN_USERNAME="${{ secrets.LOGIN_USERNAME }}" \
|
||||
LOGIN_PASSWORD="${{ secrets.LOGIN_PASSWORD }}" \
|
||||
USER_ROLE="${{ github.event.inputs.userRole }}" \
|
||||
yarn playwright test
|
||||
|
||||
- name: Upload Playwright Report
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
path: frontend/playwright-report/
|
||||
retention-days: 30
|
||||
20
Makefile
20
Makefile
@@ -201,26 +201,24 @@ docker-buildx-enterprise: go-build-enterprise js-build
|
||||
# python commands
|
||||
##############################################################
|
||||
.PHONY: py-fmt
|
||||
py-fmt: ## Run black for integration tests
|
||||
@cd tests/integration && uv run black .
|
||||
py-fmt: ## Run ruff format across the shared tests project
|
||||
@cd tests && uv run ruff format .
|
||||
|
||||
.PHONY: py-lint
|
||||
py-lint: ## Run lint for integration tests
|
||||
@cd tests/integration && uv run isort .
|
||||
@cd tests/integration && uv run autoflake .
|
||||
@cd tests/integration && uv run pylint .
|
||||
py-lint: ## Run ruff check across the shared tests project
|
||||
@cd tests && uv run ruff check --fix .
|
||||
|
||||
.PHONY: py-test-setup
|
||||
py-test-setup: ## Runs integration tests
|
||||
@cd tests/integration && uv run pytest --basetemp=./tmp/ -vv --reuse --capture=no src/bootstrap/setup.py::test_setup
|
||||
py-test-setup: ## Bring up the shared SigNoz backend used by integration and e2e tests
|
||||
@cd tests && uv run pytest --basetemp=./tmp/ -vv --reuse --capture=no integration/bootstrap/setup.py::test_setup
|
||||
|
||||
.PHONY: py-test-teardown
|
||||
py-test-teardown: ## Runs integration tests with teardown
|
||||
@cd tests/integration && uv run pytest --basetemp=./tmp/ -vv --teardown --capture=no src/bootstrap/setup.py::test_teardown
|
||||
py-test-teardown: ## Tear down the shared SigNoz backend
|
||||
@cd tests && uv run pytest --basetemp=./tmp/ -vv --teardown --capture=no integration/bootstrap/setup.py::test_teardown
|
||||
|
||||
.PHONY: py-test
|
||||
py-test: ## Runs integration tests
|
||||
@cd tests/integration && uv run pytest --basetemp=./tmp/ -vv --capture=no src/
|
||||
@cd tests && uv run pytest --basetemp=./tmp/ -vv --capture=no integration/tests/
|
||||
|
||||
.PHONY: py-clean
|
||||
py-clean: ## Clear all pycache and pytest cache from tests directory recursively
|
||||
|
||||
@@ -92,7 +92,7 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
|
||||
func(ctx context.Context, providerSettings factory.ProviderSettings, store authtypes.AuthNStore, licensing licensing.Licensing) (map[authtypes.AuthNProvider]authn.AuthN, error) {
|
||||
return signoz.NewAuthNs(ctx, providerSettings, store, licensing)
|
||||
},
|
||||
func(ctx context.Context, sqlstore sqlstore.SQLStore, _ licensing.Licensing, _ dashboard.Module) (factory.ProviderFactory[authz.AuthZ, authz.Config], error) {
|
||||
func(ctx context.Context, sqlstore sqlstore.SQLStore, _ licensing.Licensing, _ []authz.OnBeforeRoleDelete, _ dashboard.Module) (factory.ProviderFactory[authz.AuthZ, authz.Config], error) {
|
||||
openfgaDataStore, err := openfgaserver.NewSQLStore(sqlstore)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -137,12 +137,12 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
|
||||
|
||||
return authNs, nil
|
||||
},
|
||||
func(ctx context.Context, sqlstore sqlstore.SQLStore, licensing licensing.Licensing, dashboardModule dashboard.Module) (factory.ProviderFactory[authz.AuthZ, authz.Config], error) {
|
||||
func(ctx context.Context, sqlstore sqlstore.SQLStore, licensing licensing.Licensing, onBeforeRoleDelete []authz.OnBeforeRoleDelete, dashboardModule dashboard.Module) (factory.ProviderFactory[authz.AuthZ, authz.Config], error) {
|
||||
openfgaDataStore, err := openfgaserver.NewSQLStore(sqlstore)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return openfgaauthz.NewProviderFactory(sqlstore, openfgaschema.NewSchema().Get(ctx), openfgaDataStore, licensing, dashboardModule), nil
|
||||
return openfgaauthz.NewProviderFactory(sqlstore, openfgaschema.NewSchema().Get(ctx), openfgaDataStore, licensing, onBeforeRoleDelete, dashboardModule), nil
|
||||
},
|
||||
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing) dashboard.Module {
|
||||
return impldashboard.NewModule(pkgimpldashboard.NewStore(store), settings, analytics, orgGetter, queryParser, querier, licensing)
|
||||
|
||||
@@ -407,3 +407,21 @@ cloudintegration:
|
||||
agent:
|
||||
# The version of the cloud integration agent.
|
||||
version: v0.0.8
|
||||
|
||||
##################### Trace Detail #####################
|
||||
tracedetail:
|
||||
waterfall:
|
||||
# Number of spans returned per request when the trace is too large to show all at once.
|
||||
span_page_size: 500
|
||||
# Maximum depth of descendents to auto-expand for the selected span.
|
||||
max_depth_to_auto_expand: 5
|
||||
# Threshold below which all spans are returned without windowing.
|
||||
max_limit_to_select_all_spans: 10000
|
||||
|
||||
##################### Authz #################################
|
||||
authz:
|
||||
# Specifies the authz provider to use.
|
||||
provider: openfga
|
||||
openfga:
|
||||
# maximum tuples allowed per openfga write operation.
|
||||
max_tuples_per_write: 100
|
||||
|
||||
@@ -668,8 +668,8 @@ components:
|
||||
properties:
|
||||
aws:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesAWSAccountConfig'
|
||||
required:
|
||||
- aws
|
||||
azure:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesAzureAccountConfig'
|
||||
type: object
|
||||
CloudintegrationtypesAgentReport:
|
||||
nullable: true
|
||||
@@ -693,6 +693,90 @@ components:
|
||||
nullable: true
|
||||
type: array
|
||||
type: object
|
||||
CloudintegrationtypesAzureAccountConfig:
|
||||
properties:
|
||||
deploymentRegion:
|
||||
type: string
|
||||
resourceGroups:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
required:
|
||||
- deploymentRegion
|
||||
- resourceGroups
|
||||
type: object
|
||||
CloudintegrationtypesAzureConnectionArtifact:
|
||||
properties:
|
||||
cliCommand:
|
||||
type: string
|
||||
cloudPowerShellCommand:
|
||||
type: string
|
||||
required:
|
||||
- cliCommand
|
||||
- cloudPowerShellCommand
|
||||
type: object
|
||||
CloudintegrationtypesAzureIntegrationConfig:
|
||||
properties:
|
||||
deploymentRegion:
|
||||
type: string
|
||||
resourceGroups:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
telemetryCollectionStrategy:
|
||||
items:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesAzureTelemetryCollectionStrategy'
|
||||
type: array
|
||||
required:
|
||||
- deploymentRegion
|
||||
- resourceGroups
|
||||
- telemetryCollectionStrategy
|
||||
type: object
|
||||
CloudintegrationtypesAzureLogsCollectionStrategy:
|
||||
properties:
|
||||
categoryGroups:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
required:
|
||||
- categoryGroups
|
||||
type: object
|
||||
CloudintegrationtypesAzureMetricsCollectionStrategy:
|
||||
type: object
|
||||
CloudintegrationtypesAzureServiceConfig:
|
||||
properties:
|
||||
logs:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesAzureServiceLogsConfig'
|
||||
metrics:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesAzureServiceMetricsConfig'
|
||||
required:
|
||||
- logs
|
||||
- metrics
|
||||
type: object
|
||||
CloudintegrationtypesAzureServiceLogsConfig:
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
type: object
|
||||
CloudintegrationtypesAzureServiceMetricsConfig:
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
type: object
|
||||
CloudintegrationtypesAzureTelemetryCollectionStrategy:
|
||||
properties:
|
||||
logs:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesAzureLogsCollectionStrategy'
|
||||
metrics:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesAzureMetricsCollectionStrategy'
|
||||
resourceProvider:
|
||||
type: string
|
||||
resourceType:
|
||||
type: string
|
||||
required:
|
||||
- resourceProvider
|
||||
- resourceType
|
||||
type: object
|
||||
CloudintegrationtypesCloudIntegrationService:
|
||||
nullable: true
|
||||
properties:
|
||||
@@ -737,8 +821,8 @@ components:
|
||||
properties:
|
||||
aws:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesAWSConnectionArtifact'
|
||||
required:
|
||||
- aws
|
||||
azure:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesAzureConnectionArtifact'
|
||||
type: object
|
||||
CloudintegrationtypesCredentials:
|
||||
properties:
|
||||
@@ -910,8 +994,8 @@ components:
|
||||
properties:
|
||||
aws:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesAWSPostableAccountConfig'
|
||||
required:
|
||||
- aws
|
||||
azure:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesAzureAccountConfig'
|
||||
type: object
|
||||
CloudintegrationtypesPostableAgentCheckIn:
|
||||
properties:
|
||||
@@ -934,8 +1018,8 @@ components:
|
||||
properties:
|
||||
aws:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesAWSIntegrationConfig'
|
||||
required:
|
||||
- aws
|
||||
azure:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesAzureIntegrationConfig'
|
||||
type: object
|
||||
CloudintegrationtypesService:
|
||||
properties:
|
||||
@@ -972,8 +1056,8 @@ components:
|
||||
properties:
|
||||
aws:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesAWSServiceConfig'
|
||||
required:
|
||||
- aws
|
||||
azure:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesAzureServiceConfig'
|
||||
type: object
|
||||
CloudintegrationtypesServiceID:
|
||||
enum:
|
||||
@@ -990,6 +1074,8 @@ components:
|
||||
- s3sync
|
||||
- sns
|
||||
- sqs
|
||||
- storageaccountsblob
|
||||
- cdnprofile
|
||||
type: string
|
||||
CloudintegrationtypesServiceMetadata:
|
||||
properties:
|
||||
@@ -1018,16 +1104,32 @@ components:
|
||||
properties:
|
||||
aws:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesAWSTelemetryCollectionStrategy'
|
||||
required:
|
||||
- aws
|
||||
azure:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesAzureTelemetryCollectionStrategy'
|
||||
type: object
|
||||
CloudintegrationtypesUpdatableAccount:
|
||||
properties:
|
||||
config:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesAccountConfig'
|
||||
$ref: '#/components/schemas/CloudintegrationtypesUpdatableAccountConfig'
|
||||
required:
|
||||
- config
|
||||
type: object
|
||||
CloudintegrationtypesUpdatableAccountConfig:
|
||||
properties:
|
||||
aws:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesAWSAccountConfig'
|
||||
azure:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesUpdatableAzureAccountConfig'
|
||||
type: object
|
||||
CloudintegrationtypesUpdatableAzureAccountConfig:
|
||||
properties:
|
||||
resourceGroups:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
required:
|
||||
- resourceGroups
|
||||
type: object
|
||||
CloudintegrationtypesUpdatableService:
|
||||
properties:
|
||||
config:
|
||||
@@ -2287,6 +2389,125 @@ components:
|
||||
enabled:
|
||||
type: boolean
|
||||
type: object
|
||||
InframonitoringtypesHostFilter:
|
||||
properties:
|
||||
expression:
|
||||
type: string
|
||||
filterByStatus:
|
||||
$ref: '#/components/schemas/InframonitoringtypesHostStatus'
|
||||
type: object
|
||||
InframonitoringtypesHostRecord:
|
||||
properties:
|
||||
activeHostCount:
|
||||
type: integer
|
||||
cpu:
|
||||
format: double
|
||||
type: number
|
||||
diskUsage:
|
||||
format: double
|
||||
type: number
|
||||
hostName:
|
||||
type: string
|
||||
inactiveHostCount:
|
||||
type: integer
|
||||
load15:
|
||||
format: double
|
||||
type: number
|
||||
memory:
|
||||
format: double
|
||||
type: number
|
||||
meta:
|
||||
additionalProperties: {}
|
||||
nullable: true
|
||||
type: object
|
||||
status:
|
||||
$ref: '#/components/schemas/InframonitoringtypesHostStatus'
|
||||
wait:
|
||||
format: double
|
||||
type: number
|
||||
required:
|
||||
- hostName
|
||||
- status
|
||||
- activeHostCount
|
||||
- inactiveHostCount
|
||||
- cpu
|
||||
- memory
|
||||
- wait
|
||||
- load15
|
||||
- diskUsage
|
||||
- meta
|
||||
type: object
|
||||
InframonitoringtypesHostStatus:
|
||||
enum:
|
||||
- active
|
||||
- inactive
|
||||
- ""
|
||||
type: string
|
||||
InframonitoringtypesHosts:
|
||||
properties:
|
||||
endTimeBeforeRetention:
|
||||
type: boolean
|
||||
records:
|
||||
items:
|
||||
$ref: '#/components/schemas/InframonitoringtypesHostRecord'
|
||||
nullable: true
|
||||
type: array
|
||||
requiredMetricsCheck:
|
||||
$ref: '#/components/schemas/InframonitoringtypesRequiredMetricsCheck'
|
||||
total:
|
||||
type: integer
|
||||
type:
|
||||
$ref: '#/components/schemas/InframonitoringtypesResponseType'
|
||||
warning:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5QueryWarnData'
|
||||
required:
|
||||
- type
|
||||
- records
|
||||
- total
|
||||
- requiredMetricsCheck
|
||||
- endTimeBeforeRetention
|
||||
type: object
|
||||
InframonitoringtypesPostableHosts:
|
||||
properties:
|
||||
end:
|
||||
format: int64
|
||||
type: integer
|
||||
filter:
|
||||
$ref: '#/components/schemas/InframonitoringtypesHostFilter'
|
||||
groupBy:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5GroupByKey'
|
||||
nullable: true
|
||||
type: array
|
||||
limit:
|
||||
type: integer
|
||||
offset:
|
||||
type: integer
|
||||
orderBy:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5OrderBy'
|
||||
start:
|
||||
format: int64
|
||||
type: integer
|
||||
required:
|
||||
- start
|
||||
- end
|
||||
- limit
|
||||
type: object
|
||||
InframonitoringtypesRequiredMetricsCheck:
|
||||
properties:
|
||||
missingMetrics:
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
required:
|
||||
- missingMetrics
|
||||
type: object
|
||||
InframonitoringtypesResponseType:
|
||||
enum:
|
||||
- list
|
||||
- grouped_list
|
||||
type: string
|
||||
MetricsexplorertypesInspectMetricsRequest:
|
||||
properties:
|
||||
end:
|
||||
@@ -4360,6 +4581,140 @@ components:
|
||||
TimeDuration:
|
||||
format: int64
|
||||
type: integer
|
||||
TracedetailtypesEvent:
|
||||
properties:
|
||||
attributeMap:
|
||||
additionalProperties: {}
|
||||
type: object
|
||||
isError:
|
||||
type: boolean
|
||||
name:
|
||||
type: string
|
||||
timeUnixNano:
|
||||
minimum: 0
|
||||
type: integer
|
||||
type: object
|
||||
TracedetailtypesGettableWaterfallTrace:
|
||||
properties:
|
||||
endTimestampMillis:
|
||||
minimum: 0
|
||||
type: integer
|
||||
hasMissingSpans:
|
||||
type: boolean
|
||||
hasMore:
|
||||
type: boolean
|
||||
rootServiceEntryPoint:
|
||||
type: string
|
||||
rootServiceName:
|
||||
type: string
|
||||
serviceNameToTotalDurationMap:
|
||||
additionalProperties:
|
||||
minimum: 0
|
||||
type: integer
|
||||
nullable: true
|
||||
type: object
|
||||
spans:
|
||||
items:
|
||||
$ref: '#/components/schemas/TracedetailtypesWaterfallSpan'
|
||||
nullable: true
|
||||
type: array
|
||||
startTimestampMillis:
|
||||
minimum: 0
|
||||
type: integer
|
||||
totalErrorSpansCount:
|
||||
minimum: 0
|
||||
type: integer
|
||||
totalSpansCount:
|
||||
minimum: 0
|
||||
type: integer
|
||||
uncollapsedSpans:
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
type: object
|
||||
TracedetailtypesPostableWaterfall:
|
||||
properties:
|
||||
limit:
|
||||
minimum: 0
|
||||
type: integer
|
||||
selectedSpanId:
|
||||
type: string
|
||||
uncollapsedSpans:
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
type: object
|
||||
TracedetailtypesWaterfallSpan:
|
||||
properties:
|
||||
attributes:
|
||||
additionalProperties: {}
|
||||
nullable: true
|
||||
type: object
|
||||
db_name:
|
||||
type: string
|
||||
db_operation:
|
||||
type: string
|
||||
duration_nano:
|
||||
minimum: 0
|
||||
type: integer
|
||||
events:
|
||||
items:
|
||||
$ref: '#/components/schemas/TracedetailtypesEvent'
|
||||
nullable: true
|
||||
type: array
|
||||
external_http_method:
|
||||
type: string
|
||||
external_http_url:
|
||||
type: string
|
||||
flags:
|
||||
minimum: 0
|
||||
type: integer
|
||||
has_children:
|
||||
type: boolean
|
||||
has_error:
|
||||
type: boolean
|
||||
http_host:
|
||||
type: string
|
||||
http_method:
|
||||
type: string
|
||||
http_url:
|
||||
type: string
|
||||
is_remote:
|
||||
type: string
|
||||
kind_string:
|
||||
type: string
|
||||
level:
|
||||
minimum: 0
|
||||
type: integer
|
||||
name:
|
||||
type: string
|
||||
parent_span_id:
|
||||
type: string
|
||||
resource:
|
||||
additionalProperties:
|
||||
type: string
|
||||
nullable: true
|
||||
type: object
|
||||
response_status_code:
|
||||
type: string
|
||||
span_id:
|
||||
type: string
|
||||
status_code:
|
||||
type: integer
|
||||
status_code_string:
|
||||
type: string
|
||||
status_message:
|
||||
type: string
|
||||
sub_tree_node_count:
|
||||
minimum: 0
|
||||
type: integer
|
||||
trace_id:
|
||||
type: string
|
||||
trace_state:
|
||||
type: string
|
||||
type: object
|
||||
TypesAlertStatus:
|
||||
properties:
|
||||
inhibitedBy:
|
||||
@@ -7688,7 +8043,7 @@ paths:
|
||||
summary: Patch role
|
||||
tags:
|
||||
- role
|
||||
/api/v1/roles/{id}/relation/{relation}/objects:
|
||||
/api/v1/roles/{id}/relations/{relation}/objects:
|
||||
get:
|
||||
deprecated: false
|
||||
description: Gets all objects connected to the specified role via a given relation
|
||||
@@ -9853,6 +10208,72 @@ paths:
|
||||
summary: Health check
|
||||
tags:
|
||||
- health
|
||||
/api/v2/infra_monitoring/hosts:
|
||||
post:
|
||||
deprecated: false
|
||||
description: 'Returns a paginated list of hosts with key infrastructure metrics:
|
||||
CPU usage (%), memory usage (%), I/O wait (%), disk usage (%), and 15-minute
|
||||
load average. Each host includes its current status (active/inactive based
|
||||
on metrics reported in the last 10 minutes) and metadata attributes (e.g.,
|
||||
os.type). Supports filtering via a filter expression, filtering by host status,
|
||||
custom groupBy to aggregate hosts by any attribute, ordering by any of the
|
||||
five metrics, and pagination via offset/limit. The response type is ''list''
|
||||
for the default host.name grouping or ''grouped_list'' for custom groupBy
|
||||
keys. Also reports missing required metrics and whether the requested time
|
||||
range falls before the data retention boundary.'
|
||||
operationId: ListHosts
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPostableHosts'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/InframonitoringtypesHosts'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: List Hosts for Infra Monitoring
|
||||
tags:
|
||||
- inframonitoring
|
||||
/api/v2/livez:
|
||||
get:
|
||||
deprecated: false
|
||||
@@ -15708,6 +16129,76 @@ paths:
|
||||
summary: Put profile in Zeus for a deployment.
|
||||
tags:
|
||||
- zeus
|
||||
/api/v3/traces/{traceID}/waterfall:
|
||||
post:
|
||||
deprecated: false
|
||||
description: Returns the waterfall view of spans for a given trace ID with tree
|
||||
structure, metadata, and windowed pagination
|
||||
operationId: GetWaterfall
|
||||
parameters:
|
||||
- in: path
|
||||
name: traceID
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TracedetailtypesPostableWaterfall'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/TracedetailtypesGettableWaterfallTrace'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: Get waterfall view for a trace
|
||||
tags:
|
||||
- tracedetail
|
||||
/api/v5/query_range:
|
||||
post:
|
||||
deprecated: false
|
||||
|
||||
@@ -1,216 +0,0 @@
|
||||
# Integration Tests
|
||||
|
||||
SigNoz uses integration tests to verify that different components work together correctly in a real environment. These tests run against actual services (ClickHouse, PostgreSQL, etc.) to ensure end-to-end functionality.
|
||||
|
||||
## How to set up the integration test environment?
|
||||
|
||||
### Prerequisites
|
||||
|
||||
Before running integration tests, ensure you have the following installed:
|
||||
|
||||
- Python 3.13+
|
||||
- [uv](https://docs.astral.sh/uv/getting-started/installation/)
|
||||
- Docker (for containerized services)
|
||||
|
||||
### Initial Setup
|
||||
|
||||
1. Navigate to the integration tests directory:
|
||||
```bash
|
||||
cd tests/integration
|
||||
```
|
||||
|
||||
2. Install dependencies using uv:
|
||||
```bash
|
||||
uv sync
|
||||
```
|
||||
|
||||
> **_NOTE:_** the build backend could throw an error while installing `psycopg2`, pleae see https://www.psycopg.org/docs/install.html#build-prerequisites
|
||||
|
||||
### Starting the Test Environment
|
||||
|
||||
To spin up all the containers necessary for writing integration tests and keep them running:
|
||||
|
||||
```bash
|
||||
uv run pytest --basetemp=./tmp/ -vv --reuse src/bootstrap/setup.py::test_setup
|
||||
```
|
||||
|
||||
This command will:
|
||||
- Start all required services (ClickHouse, PostgreSQL, Zookeeper, etc.)
|
||||
- Keep containers running due to the `--reuse` flag
|
||||
- Verify that the setup is working correctly
|
||||
|
||||
### Stopping the Test Environment
|
||||
|
||||
When you're done writing integration tests, clean up the environment:
|
||||
|
||||
```bash
|
||||
uv run pytest --basetemp=./tmp/ -vv --teardown -s src/bootstrap/setup.py::test_teardown
|
||||
```
|
||||
|
||||
This will destroy the running integration test setup and clean up resources.
|
||||
|
||||
## Understanding the Integration Test Framework
|
||||
|
||||
Python and pytest form the foundation of the integration testing framework. Testcontainers are used to spin up disposable integration environments. Wiremock is used to spin up **test doubles** of other services.
|
||||
|
||||
- **Why Python/pytest?** It's expressive, low-boilerplate, and has powerful fixture capabilities that make integration testing straightforward. Extensive libraries for HTTP requests, JSON handling, and data analysis (numpy) make it easier to test APIs and verify data
|
||||
- **Why testcontainers?** They let us spin up isolated dependencies that match our production environment without complex setup.
|
||||
- **Why wiremock?** Well maintained, documented and extensible.
|
||||
|
||||
```
|
||||
.
|
||||
├── conftest.py
|
||||
├── fixtures
|
||||
│ ├── __init__.py
|
||||
│ ├── auth.py
|
||||
│ ├── clickhouse.py
|
||||
│ ├── fs.py
|
||||
│ ├── http.py
|
||||
│ ├── migrator.py
|
||||
│ ├── network.py
|
||||
│ ├── postgres.py
|
||||
│ ├── signoz.py
|
||||
│ ├── sql.py
|
||||
│ ├── sqlite.py
|
||||
│ ├── types.py
|
||||
│ └── zookeeper.py
|
||||
├── uv.lock
|
||||
├── pyproject.toml
|
||||
└── src
|
||||
└── bootstrap
|
||||
├── __init__.py
|
||||
├── 01_database.py
|
||||
├── 02_register.py
|
||||
└── 03_license.py
|
||||
```
|
||||
|
||||
Each test suite follows some important principles:
|
||||
|
||||
1. **Organization**: Test suites live under `src/` in self-contained packages. Fixtures (a pytest concept) live inside `fixtures/`.
|
||||
2. **Execution Order**: Files are prefixed with two-digit numbers (`01_`, `02_`, `03_`) to ensure sequential execution.
|
||||
3. **Time Constraints**: Each suite should complete in under 10 minutes (setup takes ~4 mins).
|
||||
|
||||
### Test Suite Design
|
||||
|
||||
Test suites should target functional domains or subsystems within SigNoz. When designing a test suite, consider these principles:
|
||||
|
||||
- **Functional Cohesion**: Group tests around a specific capability or service boundary
|
||||
- **Data Flow**: Follow the path of data through related components
|
||||
- **Change Patterns**: Components frequently modified together should be tested together
|
||||
|
||||
The exact boundaries for modules are intentionally flexible, allowing teams to define logical groupings based on their specific context and knowledge of the system.
|
||||
|
||||
Eg: The **bootstrap** integration test suite validates core system functionality:
|
||||
|
||||
- Database initialization
|
||||
- Version check
|
||||
|
||||
Other test suites can be **pipelines, auth, querier.**
|
||||
|
||||
## How to write an integration test?
|
||||
|
||||
Now start writing an integration test. Create a new file `src/bootstrap/05_version.py` and paste the following:
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.logger import setup_logger
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
def test_version(signoz: types.SigNoz) -> None:
|
||||
response = requests.get(signoz.self.host_config.get("/api/v1/version"), timeout=2)
|
||||
logger.info(response)
|
||||
```
|
||||
|
||||
We have written a simple test which calls the `version` endpoint of the container in step 1. In **order to just run this function, run the following command:**
|
||||
|
||||
```bash
|
||||
uv run pytest --basetemp=./tmp/ -vv --reuse src/bootstrap/05_version.py::test_version
|
||||
```
|
||||
|
||||
> Note: The `--reuse` flag is used to reuse the environment if it is already running. Always use this flag when writing and running integration tests. If you don't use this flag, the environment will be destroyed and recreated every time you run the test.
|
||||
|
||||
Here's another example of how to write a more comprehensive integration test:
|
||||
|
||||
```python
|
||||
from http import HTTPStatus
|
||||
import requests
|
||||
from fixtures import types
|
||||
from fixtures.logger import setup_logger
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
def test_user_registration(signoz: types.SigNoz) -> None:
|
||||
"""Test user registration functionality."""
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/register"),
|
||||
json={
|
||||
"name": "testuser",
|
||||
"orgId": "",
|
||||
"orgName": "test.org",
|
||||
"email": "test@example.com",
|
||||
"password": "password123Z$",
|
||||
},
|
||||
timeout=2,
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json()["setupCompleted"] is True
|
||||
```
|
||||
|
||||
## How to run integration tests?
|
||||
|
||||
### Running All Tests
|
||||
|
||||
```bash
|
||||
uv run pytest --basetemp=./tmp/ -vv --reuse src/
|
||||
```
|
||||
|
||||
### Running Specific Test Categories
|
||||
|
||||
```bash
|
||||
uv run pytest --basetemp=./tmp/ -vv --reuse src/<suite>
|
||||
|
||||
# Run querier tests
|
||||
uv run pytest --basetemp=./tmp/ -vv --reuse src/querier/
|
||||
# Run auth tests
|
||||
uv run pytest --basetemp=./tmp/ -vv --reuse src/auth/
|
||||
```
|
||||
|
||||
### Running Individual Tests
|
||||
|
||||
```bash
|
||||
uv run pytest --basetemp=./tmp/ -vv --reuse src/<suite>/<file>.py::test_name
|
||||
|
||||
# Run test_register in file 01_register.py in passwordauthn suite
|
||||
uv run pytest --basetemp=./tmp/ -vv --reuse src/passwordauthn/01_register.py::test_register
|
||||
```
|
||||
|
||||
## How to configure different options for integration tests?
|
||||
|
||||
Tests can be configured using pytest options:
|
||||
|
||||
- `--sqlstore-provider` - Choose database provider (default: postgres)
|
||||
- `--sqlite-mode` - SQLite journal mode: `delete` or `wal` (default: delete). Only relevant when `--sqlstore-provider=sqlite`.
|
||||
- `--postgres-version` - PostgreSQL version (default: 15)
|
||||
- `--clickhouse-version` - ClickHouse version (default: 25.5.6)
|
||||
- `--zookeeper-version` - Zookeeper version (default: 3.7.1)
|
||||
|
||||
Example:
|
||||
```bash
|
||||
uv run pytest --basetemp=./tmp/ -vv --reuse --sqlstore-provider=postgres --postgres-version=14 src/auth/
|
||||
```
|
||||
|
||||
## What should I remember?
|
||||
|
||||
- **Always use the `--reuse` flag** when setting up the environment to keep containers running
|
||||
- **Use the `--teardown` flag** when cleaning up to avoid resource leaks
|
||||
- **Follow the naming convention** with two-digit numeric prefixes (`01_`, `02_`) for test execution order
|
||||
- **Use proper timeouts** in HTTP requests to avoid hanging tests
|
||||
- **Clean up test data** between tests to avoid interference
|
||||
- **Use descriptive test names** that clearly indicate what is being tested
|
||||
- **Leverage fixtures** for common setup and authentication
|
||||
- **Test both success and failure scenarios** to ensure robust functionality
|
||||
- **`--sqlite-mode=wal` does not work on macOS.** The integration test environment runs SigNoz inside a Linux container with the SQLite database file mounted from the macOS host. WAL mode requires shared memory between connections, and connections crossing the VM boundary (macOS host ↔ Linux container) cannot share the WAL index, resulting in `SQLITE_IOERR_SHORT_READ`. WAL mode is tested in CI on Linux only.
|
||||
@@ -15,7 +15,6 @@ We **recommend** (almost enforce) reviewing these guides before contributing to
|
||||
- [Endpoint](endpoint.md) - HTTP endpoint patterns
|
||||
- [Flagger](flagger.md) - Feature flag patterns
|
||||
- [Handler](handler.md) - HTTP handler patterns
|
||||
- [Integration](integration.md) - Integration testing
|
||||
- [Provider](provider.md) - Dependency injection and provider patterns
|
||||
- [Packages](packages.md) - Naming, layout, and conventions for `pkg/` packages
|
||||
- [Service](service.md) - Managed service lifecycle with `factory.Service`
|
||||
|
||||
261
docs/contributing/tests/e2e.md
Normal file
261
docs/contributing/tests/e2e.md
Normal file
@@ -0,0 +1,261 @@
|
||||
# E2E Tests
|
||||
|
||||
SigNoz uses end-to-end tests to verify the frontend works correctly against a real backend. These tests use Playwright to drive a real browser against a containerized SigNoz stack that pytest brings up — the same fixture graph integration tests use, with an extra HTTP seeder container for per-spec telemetry seeding.
|
||||
|
||||
## How to set up the E2E test environment?
|
||||
|
||||
### Prerequisites
|
||||
|
||||
Before running E2E tests, ensure you have the following installed:
|
||||
|
||||
- Python 3.13+
|
||||
- [uv](https://docs.astral.sh/uv/getting-started/installation/)
|
||||
- Docker (for containerized services)
|
||||
- Node 18+ and Yarn
|
||||
|
||||
### Initial Setup
|
||||
|
||||
1. Install Python deps for the shared tests project:
|
||||
```bash
|
||||
cd tests
|
||||
uv sync
|
||||
```
|
||||
|
||||
2. Install Node deps and Playwright browsers:
|
||||
```bash
|
||||
cd e2e
|
||||
yarn install
|
||||
yarn install:browsers # one-time Playwright browser install
|
||||
```
|
||||
|
||||
### Starting the Test Environment
|
||||
|
||||
To spin up the backend stack (SigNoz, ClickHouse, Postgres, Zookeeper, Zeus mock, gateway mock, seeder, migrator-with-web) and keep it running:
|
||||
|
||||
```bash
|
||||
cd tests
|
||||
uv run pytest --basetemp=./tmp/ -vv --reuse --with-web \
|
||||
e2e/bootstrap/setup.py::test_setup
|
||||
```
|
||||
|
||||
This command will:
|
||||
- Bring up all containers via pytest fixtures
|
||||
- Register the admin user (`admin@integration.test` / `password123Z$`)
|
||||
- Apply the enterprise license (via a WireMock stub of Zeus) and dismiss the org-onboarding prompt so specs can navigate directly to feature pages
|
||||
- Start the HTTP seeder container (`tests/seeder/` — exposing `/telemetry/{traces,logs,metrics}` POST + DELETE)
|
||||
- Write backend coordinates to `tests/e2e/.env.local` (loaded by `playwright.config.ts` via dotenv)
|
||||
- Keep containers running via the `--reuse` flag
|
||||
|
||||
The `--with-web` flag builds the frontend into the SigNoz container — required for E2E. The build takes ~4 mins on a cold start.
|
||||
|
||||
### Stopping the Test Environment
|
||||
|
||||
When you're done writing E2E tests, clean up the environment:
|
||||
|
||||
```bash
|
||||
cd tests
|
||||
uv run pytest --basetemp=./tmp/ -vv --teardown \
|
||||
e2e/bootstrap/setup.py::test_teardown
|
||||
```
|
||||
|
||||
## Understanding the E2E Test Framework
|
||||
|
||||
Playwright drives a real browser (Chromium / Firefox / WebKit) against the running SigNoz frontend. The backend is brought up by the same pytest fixture graph integration tests use, so both suites share one source of truth for container lifecycle, license seeding, and test-user accounts.
|
||||
|
||||
- **Why Playwright?** First-class TypeScript support, network interception, automatic wait-for-visibility, built-in trace viewer that captures every request/response the UI triggers — so specs rarely need separate API probes alongside UI clicks.
|
||||
- **Why pytest for lifecycle?** The integration suite already owns container bring-up. Reusing it keeps the E2E stack exactly in sync with the integration stack and avoids a parallel lifecycle framework.
|
||||
- **Why a separate seeder container?** Per-spec telemetry seeding (traces / logs / metrics) needs a thin HTTP wrapper around the ClickHouse insert helpers so a browser spec can POST from inside the test. The seeder lives at `tests/seeder/`, is built from `tests/Dockerfile.seeder`, and reuses the same `fixtures/{traces,logs,metrics}.py` as integration tests.
|
||||
|
||||
```
|
||||
tests/
|
||||
├── fixtures/ # shared with integration (see integration.md)
|
||||
├── integration/ # pytest integration suite
|
||||
├── seeder/ # standalone HTTP seeder container
|
||||
│ ├── __init__.py
|
||||
│ ├── Dockerfile
|
||||
│ └── server.py # FastAPI app wrapping fixtures.{traces,logs,metrics}
|
||||
└── e2e/
|
||||
├── package.json
|
||||
├── playwright.config.ts # loads .env + .env.local via dotenv
|
||||
├── .env.example # staging-mode template
|
||||
├── .env.local # generated by bootstrap/setup.py (gitignored)
|
||||
├── bootstrap/
|
||||
│ └── setup.py # test_setup / test_teardown — pytest lifecycle
|
||||
├── fixtures/
|
||||
│ └── auth.ts # authedPage Playwright fixture + per-worker storageState cache
|
||||
├── tests/ # Playwright .spec.ts files, one dir per feature area
|
||||
│ └── alerts/
|
||||
│ └── alerts.spec.ts
|
||||
└── artifacts/ # per-run output (gitignored)
|
||||
├── html/ # HTML reporter output
|
||||
├── json/ # JSON reporter output
|
||||
└── results/ # per-test traces / screenshots / videos on failure
|
||||
```
|
||||
|
||||
Each spec follows these principles:
|
||||
|
||||
1. **Directory per feature**: `tests/e2e/tests/<feature>/*.spec.ts`. Cross-resource junction concerns (e.g. cascade-delete) go in their own file, not packed into one giant spec.
|
||||
2. **Test titles use `TC-NN`**: `test('TC-01 alerts page — tabs render', ...)`. Preserves ordering at a glance and maps to external coverage tracking.
|
||||
3. **UI-first**: drive flows through the UI. Playwright traces capture every BE request/response the UI triggers, so asserting on UI outcomes implicitly validates BE contracts. Reach for direct `page.request.*` only when the test's *purpose* is asserting a response contract (use `page.waitForResponse` on a UI click) or when a specific UI step is structurally flaky (e.g. Ant DatePicker calendar-cell indices) — and even then try UI first.
|
||||
4. **Self-contained state**: each spec creates what it needs and cleans up in `try/finally`. No global pre-seeding fixtures.
|
||||
|
||||
## How to write an E2E test?
|
||||
|
||||
Create a new file `tests/e2e/tests/alerts/smoke.spec.ts`:
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '../../fixtures/auth';
|
||||
|
||||
test('TC-01 alerts page — tabs render', async ({ authedPage: page }) => {
|
||||
await page.goto('/alerts');
|
||||
await expect(page.getByRole('tab', { name: /alert rules/i })).toBeVisible();
|
||||
await expect(page.getByRole('tab', { name: /configuration/i })).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
The `authedPage` fixture (from `tests/e2e/fixtures/auth.ts`) gives you a `Page` whose browser context is already authenticated as the admin user. First use per worker triggers one login; the resulting `storageState` is held in memory and reused for later requests.
|
||||
|
||||
To run just this test (assuming the stack is up via `test_setup`):
|
||||
|
||||
```bash
|
||||
cd tests/e2e
|
||||
npx playwright test tests/alerts/smoke.spec.ts --project=chromium
|
||||
```
|
||||
|
||||
Here's a more comprehensive example that exercises a CRUD flow via the UI:
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '../../fixtures/auth';
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test('TC-02 alerts list — create, toggle, delete', async ({ authedPage: page }) => {
|
||||
await page.goto('/alerts?tab=AlertRules');
|
||||
const name = 'smoke-rule';
|
||||
|
||||
// Seed via UI — click "New Alert", fill form, save.
|
||||
await page.getByRole('button', { name: /new alert/i }).click();
|
||||
await page.getByTestId('alert-name-input').fill(name);
|
||||
// ... fill metric / threshold / save ...
|
||||
|
||||
// Find the row and exercise the action menu.
|
||||
const row = page.locator('tr', { hasText: name });
|
||||
await expect(row).toBeVisible();
|
||||
await row.locator('[data-testid="alert-actions"] button').first().click();
|
||||
|
||||
// waitForResponse captures the network call the UI triggers — no parallel fetch needed.
|
||||
const patchWait = page.waitForResponse(
|
||||
(r) => r.url().includes('/rules/') && r.request().method() === 'PATCH',
|
||||
);
|
||||
await page.getByRole('menuitem').filter({ hasText: /^disable$/i }).click();
|
||||
await patchWait;
|
||||
await expect(row).toContainText(/disabled/i);
|
||||
});
|
||||
```
|
||||
|
||||
### Locator priority
|
||||
|
||||
1. `getByRole('button', { name: 'Submit' })`
|
||||
2. `getByLabel('Email')`
|
||||
3. `getByPlaceholder('...')`
|
||||
4. `getByText('...')`
|
||||
5. `getByTestId('...')`
|
||||
6. `locator('.ant-select')` — last resort (Ant Design dropdowns often have no semantic alternative)
|
||||
|
||||
## How to run E2E tests?
|
||||
|
||||
### Running All Tests
|
||||
|
||||
With the stack already up, from `tests/e2e/`:
|
||||
|
||||
```bash
|
||||
yarn test # headless, all projects
|
||||
```
|
||||
|
||||
### Running Specific Projects
|
||||
|
||||
```bash
|
||||
yarn test:chromium # chromium only
|
||||
yarn test:firefox
|
||||
yarn test:webkit
|
||||
```
|
||||
|
||||
### Running Specific Tests
|
||||
|
||||
```bash
|
||||
cd tests/e2e
|
||||
|
||||
# Single feature dir
|
||||
npx playwright test tests/alerts/ --project=chromium
|
||||
|
||||
# Single file
|
||||
npx playwright test tests/alerts/alerts.spec.ts --project=chromium
|
||||
|
||||
# Single test by title grep
|
||||
npx playwright test --project=chromium -g "TC-01"
|
||||
```
|
||||
|
||||
### Iterative modes
|
||||
|
||||
```bash
|
||||
yarn test:ui # Playwright UI mode — watch + step through
|
||||
yarn test:headed # headed browser
|
||||
yarn test:debug # Playwright inspector, pause-on-breakpoint
|
||||
yarn codegen # record-and-replay locator generation
|
||||
yarn report # open the last HTML report (artifacts/html)
|
||||
```
|
||||
|
||||
### Staging fallback
|
||||
|
||||
Point `SIGNOZ_E2E_BASE_URL` at a remote env via `.env` — no local backend bring-up, no `.env.local` generated, Playwright hits the URL directly:
|
||||
|
||||
```bash
|
||||
cd tests/e2e
|
||||
cp .env.example .env # fill SIGNOZ_E2E_USERNAME / PASSWORD
|
||||
yarn test:staging
|
||||
```
|
||||
|
||||
## How to configure different options for E2E tests?
|
||||
|
||||
### Environment variables
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `SIGNOZ_E2E_BASE_URL` | Base URL the browser targets. Written by `bootstrap/setup.py` for local mode; set manually for staging. |
|
||||
| `SIGNOZ_E2E_USERNAME` | Admin email. Bootstrap writes `admin@integration.test`. |
|
||||
| `SIGNOZ_E2E_PASSWORD` | Admin password. Bootstrap writes the integration-test default. |
|
||||
| `SIGNOZ_E2E_SEEDER_URL` | Seeder HTTP base URL — hit by specs that need per-test telemetry. |
|
||||
|
||||
Loading order in `playwright.config.ts`: `.env` first (user-provided, staging), then `.env.local` with `override: true` (bootstrap-generated, local mode). Anything already set in `process.env` at yarn-test time wins because dotenv doesn't touch vars that are already present.
|
||||
|
||||
### Playwright options
|
||||
|
||||
The full `playwright.config.ts` is the source of truth. Common things to tweak:
|
||||
|
||||
- `projects` — Chromium / Firefox / WebKit are enabled by default. Disable to speed up iteration.
|
||||
- `retries` — `2` on CI (`process.env.CI`), `0` locally.
|
||||
- `fullyParallel: true` — files run in parallel by worker; within a file, use `test.describe.configure({ mode: 'serial' })` if tests share list pages / mutate shared state.
|
||||
- `trace: 'on-first-retry'`, `screenshot: 'only-on-failure'`, `video: 'retain-on-failure'` — default diagnostic artifacts land in `artifacts/results/<test>/`.
|
||||
|
||||
### Pytest options (bootstrap side)
|
||||
|
||||
The same pytest flags integration tests expose work here, since E2E reuses the shared fixture graph:
|
||||
|
||||
- `--reuse` — keep containers warm between runs (required for all iteration).
|
||||
- `--teardown` — tear everything down.
|
||||
- `--with-web` — build the frontend into the SigNoz container. **Required for E2E**; integration tests don't need it.
|
||||
- `--sqlstore-provider`, `--postgres-version`, `--clickhouse-version`, etc. — see `docs/contributing/integration.md`.
|
||||
|
||||
## What should I remember?
|
||||
|
||||
- **Always use the `--reuse` flag** when setting up the E2E stack. `--with-web` adds a ~4 min frontend build; you only want to pay that once.
|
||||
- **Don't teardown before setup.** `--reuse` correctly handles partially-set-up state, so chaining teardown → setup wastes time.
|
||||
- **Prefer UI-driven flows.** Playwright captures BE requests in the trace; a parallel `fetch` probe is almost always redundant. Drop to `page.request.*` only when the UI can't reach what you need.
|
||||
- **Use `page.waitForResponse` on UI clicks** to assert BE contracts — it still exercises the UI trigger path.
|
||||
- **Title every test `TC-NN <short description>`** — keeps the suite navigable and reportable.
|
||||
- **Split by resource, not by regression suite.** One spec per feature resource; cross-resource junction concerns (cascade-delete, linked-edit) get their own file.
|
||||
- **Use short descriptive resource names** (`alerts-list-rule`, `labels-rule`, `downtime-once`) — no timestamp disambiguation. Each test owns its resources and cleans up in `try/finally`.
|
||||
- **Never commit `test.only`** — a pre-commit check or CI runs with `forbidOnly: true`.
|
||||
- **Prefer explicit waits over `page.waitForTimeout(ms)`.** `await expect(locator).toBeVisible()` is always better than `waitForTimeout(5000)`.
|
||||
- **Unique test names won't save you from shared-tenant state.** When two tests hit the same list page, either serialize (`describe.configure({ mode: 'serial' })`) or isolate cleanup religiously.
|
||||
- **Artifacts go to `tests/e2e/artifacts/`** — HTML report at `artifacts/html`, traces at `artifacts/results/<test>/`. All gitignored; archive the dir in CI.
|
||||
251
docs/contributing/tests/integration.md
Normal file
251
docs/contributing/tests/integration.md
Normal file
@@ -0,0 +1,251 @@
|
||||
# Integration Tests
|
||||
|
||||
SigNoz uses integration tests to verify that different components work together correctly in a real environment. These tests run against actual services (ClickHouse, PostgreSQL, SigNoz, Zeus mock, Keycloak, etc.) spun up as containers, so suites exercise the same code paths production does.
|
||||
|
||||
## How to set up the integration test environment?
|
||||
|
||||
### Prerequisites
|
||||
|
||||
Before running integration tests, ensure you have the following installed:
|
||||
|
||||
- Python 3.13+
|
||||
- [uv](https://docs.astral.sh/uv/getting-started/installation/)
|
||||
- Docker (for containerized services)
|
||||
|
||||
### Initial Setup
|
||||
|
||||
1. Navigate to the shared tests project:
|
||||
```bash
|
||||
cd tests
|
||||
```
|
||||
|
||||
2. Install dependencies using uv:
|
||||
```bash
|
||||
uv sync
|
||||
```
|
||||
|
||||
> **_NOTE:_** the build backend could throw an error while installing `psycopg2`, please see https://www.psycopg.org/docs/install.html#build-prerequisites
|
||||
|
||||
### Starting the Test Environment
|
||||
|
||||
To spin up all the containers necessary for writing integration tests and keep them running:
|
||||
|
||||
```bash
|
||||
make py-test-setup
|
||||
```
|
||||
|
||||
Under the hood this runs, from `tests/`:
|
||||
|
||||
```bash
|
||||
uv run pytest --basetemp=./tmp/ -vv --reuse integration/bootstrap/setup.py::test_setup
|
||||
```
|
||||
|
||||
This command will:
|
||||
- Start all required services (ClickHouse, PostgreSQL, Zookeeper, SigNoz, Zeus mock, gateway mock)
|
||||
- Register an admin user
|
||||
- Keep containers running via the `--reuse` flag
|
||||
|
||||
### Stopping the Test Environment
|
||||
|
||||
When you're done writing integration tests, clean up the environment:
|
||||
|
||||
```bash
|
||||
make py-test-teardown
|
||||
```
|
||||
|
||||
Which runs:
|
||||
|
||||
```bash
|
||||
uv run pytest --basetemp=./tmp/ -vv --teardown integration/bootstrap/setup.py::test_teardown
|
||||
```
|
||||
|
||||
This destroys the running integration test setup and cleans up resources.
|
||||
|
||||
## Understanding the Integration Test Framework
|
||||
|
||||
Python and pytest form the foundation of the integration testing framework. Testcontainers are used to spin up disposable integration environments. WireMock is used to spin up **test doubles** of external services (Zeus cloud API, gateway, etc.).
|
||||
|
||||
- **Why Python/pytest?** It's expressive, low-boilerplate, and has powerful fixture capabilities that make integration testing straightforward. Extensive libraries for HTTP requests, JSON handling, and data analysis (numpy) make it easier to test APIs and verify data.
|
||||
- **Why testcontainers?** They let us spin up isolated dependencies that match our production environment without complex setup.
|
||||
- **Why WireMock?** Well maintained, documented, and extensible.
|
||||
|
||||
```
|
||||
tests/
|
||||
├── conftest.py # pytest_plugins registration
|
||||
├── pyproject.toml
|
||||
├── uv.lock
|
||||
├── fixtures/ # shared fixture library (flat package)
|
||||
│ ├── __init__.py
|
||||
│ ├── auth.py # admin/editor/viewer users, tokens, license
|
||||
│ ├── clickhouse.py
|
||||
│ ├── http.py # WireMock helpers
|
||||
│ ├── keycloak.py # IdP container
|
||||
│ ├── postgres.py
|
||||
│ ├── signoz.py # SigNoz-backend container
|
||||
│ ├── sql.py
|
||||
│ ├── types.py
|
||||
│ └── ... # logs, metrics, traces, alerts, dashboards, ...
|
||||
├── integration/
|
||||
│ ├── bootstrap/
|
||||
│ │ └── setup.py # test_setup / test_teardown
|
||||
│ ├── testdata/ # JSON / JSONL / YAML inputs per suite
|
||||
│ └── tests/ # one directory per feature area
|
||||
│ ├── alerts/
|
||||
│ │ ├── 01_*.py # numbered suite files
|
||||
│ │ └── conftest.py # optional suite-local fixtures
|
||||
│ ├── auditquerier/
|
||||
│ ├── cloudintegrations/
|
||||
│ ├── dashboard/
|
||||
│ ├── passwordauthn/
|
||||
│ ├── querier/
|
||||
│ └── ...
|
||||
└── e2e/ # Playwright suite (see docs/contributing/e2e.md)
|
||||
```
|
||||
|
||||
Each test suite follows these principles:
|
||||
|
||||
1. **Organization**: Suites live under `tests/integration/tests/` in self-contained packages. Shared fixtures live in the top-level `tests/fixtures/` package so the e2e tree can reuse them.
|
||||
2. **Execution Order**: Files are prefixed with two-digit numbers (`01_`, `02_`, `03_`) to ensure sequential execution when tests depend on ordering.
|
||||
3. **Time Constraints**: Each suite should complete in under 10 minutes (setup takes ~4 mins).
|
||||
|
||||
### Test Suite Design
|
||||
|
||||
Test suites should target functional domains or subsystems within SigNoz. When designing a test suite, consider these principles:
|
||||
|
||||
- **Functional Cohesion**: Group tests around a specific capability or service boundary
|
||||
- **Data Flow**: Follow the path of data through related components
|
||||
- **Change Patterns**: Components frequently modified together should be tested together
|
||||
|
||||
The exact boundaries for suites are intentionally flexible, allowing contributors to define logical groupings based on their domain knowledge. Current suites cover alerts, audit querier, callback authn, cloud integrations, dashboards, ingestion keys, logs pipelines, password authn, preferences, querier, raw export data, roles, root user, service accounts, and TTL.
|
||||
|
||||
## How to write an integration test?
|
||||
|
||||
Now start writing an integration test. Create a new file `tests/integration/tests/bootstrap/01_version.py` and paste the following:
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.logger import setup_logger
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
|
||||
def test_version(signoz: types.SigNoz) -> None:
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/version"),
|
||||
timeout=2,
|
||||
)
|
||||
logger.info(response)
|
||||
```
|
||||
|
||||
We have written a simple test which calls the `version` endpoint of the SigNoz backend. **To run just this function, run the following command:**
|
||||
|
||||
```bash
|
||||
cd tests
|
||||
uv run pytest --basetemp=./tmp/ -vv --reuse \
|
||||
integration/tests/bootstrap/01_version.py::test_version
|
||||
```
|
||||
|
||||
> **Note:** The `--reuse` flag is used to reuse the environment if it is already running. Always use this flag when writing and running integration tests. Without it the environment is destroyed and recreated every run.
|
||||
|
||||
Here's another example of how to write a more comprehensive integration test:
|
||||
|
||||
```python
|
||||
from http import HTTPStatus
|
||||
import requests
|
||||
from fixtures import types
|
||||
from fixtures.logger import setup_logger
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
|
||||
def test_user_registration(signoz: types.SigNoz) -> None:
|
||||
"""Test user registration functionality."""
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/register"),
|
||||
json={
|
||||
"name": "testuser",
|
||||
"orgId": "",
|
||||
"orgName": "test.org",
|
||||
"email": "test@example.com",
|
||||
"password": "password123Z$",
|
||||
},
|
||||
timeout=2,
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json()["setupCompleted"] is True
|
||||
```
|
||||
|
||||
Test inputs (JSON fixtures, expected payloads) go under `tests/integration/testdata/<suite>/` and are loaded via `fixtures.fs.get_testdata_file_path`.
|
||||
|
||||
## How to run integration tests?
|
||||
|
||||
### Running All Tests
|
||||
|
||||
```bash
|
||||
make py-test
|
||||
```
|
||||
|
||||
Which runs:
|
||||
|
||||
```bash
|
||||
uv run pytest --basetemp=./tmp/ -vv integration/tests/
|
||||
```
|
||||
|
||||
### Running Specific Test Categories
|
||||
|
||||
```bash
|
||||
cd tests
|
||||
uv run pytest --basetemp=./tmp/ -vv --reuse integration/tests/<suite>/
|
||||
|
||||
# Run querier tests
|
||||
uv run pytest --basetemp=./tmp/ -vv --reuse integration/tests/querier/
|
||||
# Run passwordauthn tests
|
||||
uv run pytest --basetemp=./tmp/ -vv --reuse integration/tests/passwordauthn/
|
||||
```
|
||||
|
||||
### Running Individual Tests
|
||||
|
||||
```bash
|
||||
uv run pytest --basetemp=./tmp/ -vv --reuse \
|
||||
integration/tests/<suite>/<file>.py::test_name
|
||||
|
||||
# Run test_register in 01_register.py in the passwordauthn suite
|
||||
uv run pytest --basetemp=./tmp/ -vv --reuse \
|
||||
integration/tests/passwordauthn/01_register.py::test_register
|
||||
```
|
||||
|
||||
## How to configure different options for integration tests?
|
||||
|
||||
Tests can be configured using pytest options:
|
||||
|
||||
- `--sqlstore-provider` — Choose the SQL store provider (default: `postgres`)
|
||||
- `--sqlite-mode` — SQLite journal mode: `delete` or `wal` (default: `delete`). Only relevant when `--sqlstore-provider=sqlite`.
|
||||
- `--postgres-version` — PostgreSQL version (default: `15`)
|
||||
- `--clickhouse-version` — ClickHouse version (default: `25.5.6`)
|
||||
- `--zookeeper-version` — Zookeeper version (default: `3.7.1`)
|
||||
- `--schema-migrator-version` — SigNoz schema migrator version (default: `v0.144.2`)
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
uv run pytest --basetemp=./tmp/ -vv --reuse \
|
||||
--sqlstore-provider=postgres --postgres-version=14 \
|
||||
integration/tests/passwordauthn/
|
||||
```
|
||||
|
||||
## What should I remember?
|
||||
|
||||
- **Always use the `--reuse` flag** when setting up the environment or running tests to keep containers warm. Without it every run rebuilds the stack (~4 mins).
|
||||
- **Use the `--teardown` flag** only when cleaning up — mixing `--teardown` with `--reuse` is a contradiction.
|
||||
- **Do not pre-emptively teardown before setup.** If the stack is partially up, `--reuse` picks up from wherever it is. `make py-test-teardown` then `make py-test-setup` wastes minutes.
|
||||
- **Follow the naming convention** with two-digit numeric prefixes (`01_`, `02_`) for ordered test execution within a suite.
|
||||
- **Use proper timeouts** in HTTP requests to avoid hanging tests (`timeout=5` is typical).
|
||||
- **Clean up test data** between tests in the same suite to avoid interference — or rely on a fresh SigNoz container if you need full isolation.
|
||||
- **Use descriptive test names** that clearly indicate what is being tested.
|
||||
- **Leverage fixtures** for common setup. The shared fixture package is at `tests/fixtures/` — reuse before adding new ones.
|
||||
- **Test both success and failure scenarios** (4xx / 5xx paths) to ensure robust functionality.
|
||||
- **Run `make py-fmt` and `make py-lint` before committing** Python changes — black + isort + autoflake + pylint.
|
||||
- **`--sqlite-mode=wal` does not work on macOS.** The integration test environment runs SigNoz inside a Linux container with the SQLite database file mounted from the macOS host. WAL mode requires shared memory between connections, and connections crossing the VM boundary (macOS host ↔ Linux container) cannot share the WAL index, resulting in `SQLITE_IOERR_SHORT_READ`. WAL mode is tested in CI on Linux only.
|
||||
@@ -20,20 +20,23 @@ import (
|
||||
)
|
||||
|
||||
type provider struct {
|
||||
pkgAuthzService authz.AuthZ
|
||||
openfgaServer *openfgaserver.Server
|
||||
licensing licensing.Licensing
|
||||
store authtypes.RoleStore
|
||||
registry []authz.RegisterTypeable
|
||||
config authz.Config
|
||||
pkgAuthzService authz.AuthZ
|
||||
openfgaServer *openfgaserver.Server
|
||||
licensing licensing.Licensing
|
||||
store authtypes.RoleStore
|
||||
registry []authz.RegisterTypeable
|
||||
settings factory.ScopedProviderSettings
|
||||
onBeforeRoleDelete []authz.OnBeforeRoleDelete
|
||||
}
|
||||
|
||||
func NewProviderFactory(sqlstore sqlstore.SQLStore, openfgaSchema []openfgapkgtransformer.ModuleFile, openfgaDataStore storage.OpenFGADatastore, licensing licensing.Licensing, registry ...authz.RegisterTypeable) factory.ProviderFactory[authz.AuthZ, authz.Config] {
|
||||
func NewProviderFactory(sqlstore sqlstore.SQLStore, openfgaSchema []openfgapkgtransformer.ModuleFile, openfgaDataStore storage.OpenFGADatastore, licensing licensing.Licensing, onBeforeRoleDelete []authz.OnBeforeRoleDelete, registry ...authz.RegisterTypeable) factory.ProviderFactory[authz.AuthZ, authz.Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("openfga"), func(ctx context.Context, ps factory.ProviderSettings, config authz.Config) (authz.AuthZ, error) {
|
||||
return newOpenfgaProvider(ctx, ps, config, sqlstore, openfgaSchema, openfgaDataStore, licensing, registry)
|
||||
return newOpenfgaProvider(ctx, ps, config, sqlstore, openfgaSchema, openfgaDataStore, licensing, onBeforeRoleDelete, registry)
|
||||
})
|
||||
}
|
||||
|
||||
func newOpenfgaProvider(ctx context.Context, settings factory.ProviderSettings, config authz.Config, sqlstore sqlstore.SQLStore, openfgaSchema []openfgapkgtransformer.ModuleFile, openfgaDataStore storage.OpenFGADatastore, licensing licensing.Licensing, registry []authz.RegisterTypeable) (authz.AuthZ, error) {
|
||||
func newOpenfgaProvider(ctx context.Context, settings factory.ProviderSettings, config authz.Config, sqlstore sqlstore.SQLStore, openfgaSchema []openfgapkgtransformer.ModuleFile, openfgaDataStore storage.OpenFGADatastore, licensing licensing.Licensing, onBeforeRoleDelete []authz.OnBeforeRoleDelete, registry []authz.RegisterTypeable) (authz.AuthZ, error) {
|
||||
pkgOpenfgaAuthzProvider := pkgopenfgaauthz.NewProviderFactory(sqlstore, openfgaSchema, openfgaDataStore)
|
||||
pkgAuthzService, err := pkgOpenfgaAuthzProvider.New(ctx, settings, config)
|
||||
if err != nil {
|
||||
@@ -45,12 +48,17 @@ func newOpenfgaProvider(ctx context.Context, settings factory.ProviderSettings,
|
||||
return nil, err
|
||||
}
|
||||
|
||||
scopedSettings := factory.NewScopedProviderSettings(settings, "github.com/SigNoz/signoz/ee/authz/openfgaauthz")
|
||||
|
||||
return &provider{
|
||||
pkgAuthzService: pkgAuthzService,
|
||||
openfgaServer: openfgaServer,
|
||||
licensing: licensing,
|
||||
store: sqlauthzstore.NewSqlAuthzStore(sqlstore),
|
||||
registry: registry,
|
||||
config: config,
|
||||
pkgAuthzService: pkgAuthzService,
|
||||
openfgaServer: openfgaServer,
|
||||
licensing: licensing,
|
||||
store: sqlauthzstore.NewSqlAuthzStore(sqlstore),
|
||||
registry: registry,
|
||||
settings: scopedSettings,
|
||||
onBeforeRoleDelete: onBeforeRoleDelete,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -78,14 +86,18 @@ func (provider *provider) BatchCheck(ctx context.Context, tupleReq map[string]*o
|
||||
return provider.openfgaServer.BatchCheck(ctx, tupleReq)
|
||||
}
|
||||
|
||||
func (provider *provider) ListObjects(ctx context.Context, subject string, relation authtypes.Relation, typeable authtypes.Typeable) ([]*authtypes.Object, error) {
|
||||
return provider.openfgaServer.ListObjects(ctx, subject, relation, typeable)
|
||||
func (provider *provider) ListObjects(ctx context.Context, subject string, relation authtypes.Relation, objectType authtypes.Type) ([]*authtypes.Object, error) {
|
||||
return provider.openfgaServer.ListObjects(ctx, subject, relation, objectType)
|
||||
}
|
||||
|
||||
func (provider *provider) Write(ctx context.Context, additions []*openfgav1.TupleKey, deletions []*openfgav1.TupleKey) error {
|
||||
return provider.openfgaServer.Write(ctx, additions, deletions)
|
||||
}
|
||||
|
||||
func (provider *provider) ReadTuples(ctx context.Context, tupleKey *openfgav1.ReadRequestTupleKey) ([]*openfgav1.TupleKey, error) {
|
||||
return provider.openfgaServer.ReadTuples(ctx, tupleKey)
|
||||
}
|
||||
|
||||
func (provider *provider) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*authtypes.Role, error) {
|
||||
return provider.pkgAuthzService.Get(ctx, orgID, id)
|
||||
}
|
||||
@@ -146,7 +158,7 @@ func (provider *provider) Create(ctx context.Context, orgID valuer.UUID, role *a
|
||||
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
return provider.store.Create(ctx, authtypes.NewStorableRoleFromRole(role))
|
||||
return provider.store.Create(ctx, role)
|
||||
}
|
||||
|
||||
func (provider *provider) GetOrCreate(ctx context.Context, orgID valuer.UUID, role *authtypes.Role) (*authtypes.Role, error) {
|
||||
@@ -163,10 +175,10 @@ func (provider *provider) GetOrCreate(ctx context.Context, orgID valuer.UUID, ro
|
||||
}
|
||||
|
||||
if existingRole != nil {
|
||||
return authtypes.NewRoleFromStorableRole(existingRole), nil
|
||||
return existingRole, nil
|
||||
}
|
||||
|
||||
err = provider.store.Create(ctx, authtypes.NewStorableRoleFromRole(role))
|
||||
err = provider.store.Create(ctx, role)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -175,14 +187,13 @@ func (provider *provider) GetOrCreate(ctx context.Context, orgID valuer.UUID, ro
|
||||
}
|
||||
|
||||
func (provider *provider) GetResources(_ context.Context) []*authtypes.Resource {
|
||||
typeables := make([]authtypes.Typeable, 0)
|
||||
for _, register := range provider.registry {
|
||||
typeables = append(typeables, register.MustGetTypeables()...)
|
||||
}
|
||||
|
||||
typeables = append(typeables, provider.MustGetTypeables()...)
|
||||
resources := make([]*authtypes.Resource, 0)
|
||||
for _, typeable := range typeables {
|
||||
for _, register := range provider.registry {
|
||||
for _, typeable := range register.MustGetTypeables() {
|
||||
resources = append(resources, &authtypes.Resource{Name: typeable.Name(), Type: typeable.Type()})
|
||||
}
|
||||
}
|
||||
for _, typeable := range provider.MustGetTypeables() {
|
||||
resources = append(resources, &authtypes.Resource{Name: typeable.Name(), Type: typeable.Type()})
|
||||
}
|
||||
|
||||
@@ -201,21 +212,23 @@ func (provider *provider) GetObjects(ctx context.Context, orgID valuer.UUID, id
|
||||
}
|
||||
|
||||
objects := make([]*authtypes.Object, 0)
|
||||
for _, resource := range provider.GetResources(ctx) {
|
||||
if slices.Contains(authtypes.TypeableRelations[resource.Type], relation) {
|
||||
resourceObjects, err := provider.
|
||||
ListObjects(
|
||||
ctx,
|
||||
authtypes.MustNewSubject(authtypes.TypeableRole, storableRole.Name, orgID, &authtypes.RelationAssignee),
|
||||
relation,
|
||||
authtypes.MustNewTypeableFromType(resource.Type, resource.Name),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
objects = append(objects, resourceObjects...)
|
||||
for _, objectType := range provider.getUniqueTypes() {
|
||||
if !slices.Contains(authtypes.TypeableRelations[objectType], relation) {
|
||||
continue
|
||||
}
|
||||
|
||||
resourceObjects, err := provider.
|
||||
ListObjects(
|
||||
ctx,
|
||||
authtypes.MustNewSubject(authtypes.TypeableRole, storableRole.Name, orgID, &authtypes.RelationAssignee),
|
||||
relation,
|
||||
objectType,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
objects = append(objects, resourceObjects...)
|
||||
}
|
||||
|
||||
return objects, nil
|
||||
@@ -227,7 +240,7 @@ func (provider *provider) Patch(ctx context.Context, orgID valuer.UUID, role *au
|
||||
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
return provider.store.Update(ctx, orgID, authtypes.NewStorableRoleFromRole(role))
|
||||
return provider.store.Update(ctx, orgID, role)
|
||||
}
|
||||
|
||||
func (provider *provider) PatchObjects(ctx context.Context, orgID valuer.UUID, name string, relation authtypes.Relation, additions, deletions []*authtypes.Object) error {
|
||||
@@ -260,17 +273,26 @@ func (provider *provider) Delete(ctx context.Context, orgID valuer.UUID, id valu
|
||||
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
storableRole, err := provider.store.Get(ctx, orgID, id)
|
||||
role, err := provider.store.Get(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
role := authtypes.NewRoleFromStorableRole(storableRole)
|
||||
err = role.ErrIfManaged()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, cb := range provider.onBeforeRoleDelete {
|
||||
if err := cb(ctx, orgID, id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := provider.deleteTuples(ctx, role.Name, orgID); err != nil {
|
||||
return errors.WithAdditionalf(err, "failed to delete tuples for the role: %s", role.Name)
|
||||
}
|
||||
|
||||
return provider.store.Delete(ctx, orgID, id)
|
||||
}
|
||||
|
||||
@@ -346,3 +368,62 @@ func (provider *provider) getManagedRoleTransactionTuples(orgID valuer.UUID) ([]
|
||||
|
||||
return tuples, nil
|
||||
}
|
||||
|
||||
func (provider *provider) deleteTuples(ctx context.Context, roleName string, orgID valuer.UUID) error {
|
||||
subject := authtypes.MustNewSubject(authtypes.TypeableRole, roleName, orgID, &authtypes.RelationAssignee)
|
||||
|
||||
tuples := make([]*openfgav1.TupleKey, 0)
|
||||
for _, objectType := range provider.getUniqueTypes() {
|
||||
typeTuples, err := provider.ReadTuples(ctx, &openfgav1.ReadRequestTupleKey{
|
||||
User: subject,
|
||||
Object: objectType.StringValue() + ":",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tuples = append(tuples, typeTuples...)
|
||||
}
|
||||
|
||||
if len(tuples) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
for idx := 0; idx < len(tuples); idx += provider.config.OpenFGA.MaxTuplesPerWrite {
|
||||
end := idx + provider.config.OpenFGA.MaxTuplesPerWrite
|
||||
if end > len(tuples) {
|
||||
end = len(tuples)
|
||||
}
|
||||
|
||||
err := provider.Write(ctx, nil, tuples[idx:end])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *provider) getUniqueTypes() []authtypes.Type {
|
||||
seen := make(map[string]struct{})
|
||||
uniqueTypes := make([]authtypes.Type, 0)
|
||||
for _, register := range provider.registry {
|
||||
for _, typeable := range register.MustGetTypeables() {
|
||||
typeKey := typeable.Type().StringValue()
|
||||
if _, ok := seen[typeKey]; ok {
|
||||
continue
|
||||
}
|
||||
seen[typeKey] = struct{}{}
|
||||
uniqueTypes = append(uniqueTypes, typeable.Type())
|
||||
}
|
||||
}
|
||||
for _, typeable := range provider.MustGetTypeables() {
|
||||
typeKey := typeable.Type().StringValue()
|
||||
if _, ok := seen[typeKey]; ok {
|
||||
continue
|
||||
}
|
||||
seen[typeKey] = struct{}{}
|
||||
uniqueTypes = append(uniqueTypes, typeable.Type())
|
||||
}
|
||||
|
||||
return uniqueTypes
|
||||
}
|
||||
|
||||
@@ -110,10 +110,14 @@ func (server *Server) BatchCheck(ctx context.Context, tupleReq map[string]*openf
|
||||
return server.pkgAuthzService.BatchCheck(ctx, tupleReq)
|
||||
}
|
||||
|
||||
func (server *Server) ListObjects(ctx context.Context, subject string, relation authtypes.Relation, typeable authtypes.Typeable) ([]*authtypes.Object, error) {
|
||||
return server.pkgAuthzService.ListObjects(ctx, subject, relation, typeable)
|
||||
func (server *Server) ListObjects(ctx context.Context, subject string, relation authtypes.Relation, objectType authtypes.Type) ([]*authtypes.Object, error) {
|
||||
return server.pkgAuthzService.ListObjects(ctx, subject, relation, objectType)
|
||||
}
|
||||
|
||||
func (server *Server) Write(ctx context.Context, additions []*openfgav1.TupleKey, deletions []*openfgav1.TupleKey) error {
|
||||
return server.pkgAuthzService.Write(ctx, additions, deletions)
|
||||
}
|
||||
|
||||
func (server *Server) ReadTuples(ctx context.Context, tupleKey *openfgav1.ReadRequestTupleKey) ([]*openfgav1.TupleKey, error) {
|
||||
return server.pkgAuthzService.ReadTuples(ctx, tupleKey)
|
||||
}
|
||||
|
||||
@@ -286,6 +286,8 @@
|
||||
// Prevents useStore.getState() - export standalone actions instead
|
||||
"signoz/no-navigator-clipboard": "error",
|
||||
// Prevents navigator.clipboard - use useCopyToClipboard hook instead (disabled in tests via override)
|
||||
"signoz/no-raw-absolute-path": "warn",
|
||||
// Prevents window.open(path), window.location.origin + path, window.location.href = path
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
@@ -597,8 +599,9 @@
|
||||
"rules": {
|
||||
"import/first": "off",
|
||||
// Should ignore due to mocks
|
||||
"signoz/no-navigator-clipboard": "off"
|
||||
// Tests can use navigator.clipboard directly
|
||||
"signoz/no-navigator-clipboard": "off",
|
||||
// Tests can use navigator.clipboard directly,
|
||||
"signoz/no-raw-absolute-path":"off"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// oxlint-disable-next-line typescript/no-require-imports
|
||||
const path = require('path');
|
||||
|
||||
module.exports = {
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
# SigNoz E2E Test Plan
|
||||
|
||||
This directory contains the structured test plan for the SigNoz application. Each subfolder corresponds to a main module or feature area, and contains scenario files for all user journeys, edge cases, and cross-module flows. These documents serve as the basis for generating Playwright MCP-driven E2E tests.
|
||||
|
||||
## Structure
|
||||
|
||||
- Each main module (e.g., logs, traces, dashboards, alerts, settings, etc.) has its own folder or markdown file.
|
||||
- Each file contains detailed scenario templates, including preconditions, step-by-step actions, and expected outcomes.
|
||||
- Use these documents to write, review, and update test cases as the application evolves.
|
||||
|
||||
## Folders & Files
|
||||
|
||||
- `logs/` — Logs module scenarios
|
||||
- `traces/` — Traces module scenarios
|
||||
- `metrics/` — Metrics module scenarios
|
||||
- `dashboards/` — Dashboards module scenarios
|
||||
- `alerts/` — Alerts module scenarios
|
||||
- `services/` — Services module scenarios
|
||||
- `settings/` — Settings and all sub-settings scenarios
|
||||
- `onboarding/` — Onboarding and signup flows
|
||||
- `navigation/` — Navigation, sidebar, and cross-module flows
|
||||
- `exceptions/` — Exception and error handling scenarios
|
||||
- `external-apis/` — External API monitoring scenarios
|
||||
- `messaging-queues/` — Messaging queue scenarios
|
||||
- `infrastructure/` — Infrastructure monitoring scenarios
|
||||
- `help-support/` — Help & support scenarios
|
||||
- `user-preferences/` — User preferences and personalization scenarios
|
||||
- `service-map/` — Service map scenarios
|
||||
- `saved-views/` — Saved views scenarios
|
||||
@@ -1,16 +0,0 @@
|
||||
# Settings Module Test Plan
|
||||
|
||||
This folder contains E2E test scenarios for the Settings module and all sub-settings.
|
||||
|
||||
## Scenario Categories
|
||||
|
||||
- General settings (org/workspace, branding, version info)
|
||||
- Billing settings
|
||||
- Members & SSO
|
||||
- Custom domain
|
||||
- Integrations
|
||||
- Notification channels
|
||||
- API keys
|
||||
- Ingestion
|
||||
- Account settings (profile, password, preferences)
|
||||
- Keyboard shortcuts
|
||||
@@ -1,43 +0,0 @@
|
||||
# Account Settings E2E Scenarios (Updated)
|
||||
|
||||
## 1. Update Name
|
||||
|
||||
- **Precondition:** User is logged in
|
||||
- **Steps:**
|
||||
1. Click 'Update name' button
|
||||
2. Edit name field in the modal/dialog
|
||||
3. Save changes
|
||||
- **Expected:** Name is updated in the UI
|
||||
|
||||
## 2. Update Email
|
||||
|
||||
- **Note:** The email field is not editable in the current UI.
|
||||
|
||||
## 3. Reset Password
|
||||
|
||||
- **Precondition:** User is logged in
|
||||
- **Steps:**
|
||||
1. Click 'Reset password' button
|
||||
2. Complete reset flow (modal/dialog or external flow)
|
||||
- **Expected:** Password is reset
|
||||
|
||||
## 4. Toggle 'Adapt to my timezone'
|
||||
|
||||
- **Precondition:** User is logged in
|
||||
- **Steps:**
|
||||
1. Toggle 'Adapt to my timezone' switch
|
||||
- **Expected:** Timezone adapts accordingly (UI feedback/confirmation should be checked)
|
||||
|
||||
## 5. Toggle Theme (Dark/Light)
|
||||
|
||||
- **Precondition:** User is logged in
|
||||
- **Steps:**
|
||||
1. Toggle theme radio buttons ('Dark', 'Light Beta')
|
||||
- **Expected:** Theme changes
|
||||
|
||||
## 6. Toggle Sidebar Always Open
|
||||
|
||||
- **Precondition:** User is logged in
|
||||
- **Steps:**
|
||||
1. Toggle 'Keep the primary sidebar always open' switch
|
||||
- **Expected:** Sidebar remains open/closed as per toggle
|
||||
@@ -1,26 +0,0 @@
|
||||
# API Keys E2E Scenarios (Updated)
|
||||
|
||||
## 1. Create a New API Key
|
||||
|
||||
- **Precondition:** User is admin
|
||||
- **Steps:**
|
||||
1. Click 'New Key' button
|
||||
2. Enter details in the modal/dialog
|
||||
3. Click 'Save'
|
||||
- **Expected:** API key is created and listed in the table
|
||||
|
||||
## 2. Revoke an API Key
|
||||
|
||||
- **Precondition:** API key exists
|
||||
- **Steps:**
|
||||
1. In the table, locate the API key row
|
||||
2. Click the revoke/delete button (icon button in the Action column)
|
||||
3. Confirm if prompted
|
||||
- **Expected:** API key is revoked/removed from the table
|
||||
|
||||
## 3. View API Key Usage
|
||||
|
||||
- **Precondition:** API key exists
|
||||
- **Steps:**
|
||||
1. View the 'Last used' and 'Expired' columns in the table
|
||||
- **Expected:** Usage data is displayed for each API key
|
||||
@@ -1,17 +0,0 @@
|
||||
# Billing Settings E2E Scenarios (Updated)
|
||||
|
||||
## 1. View Billing Information
|
||||
|
||||
- **Precondition:** User is admin
|
||||
- **Steps:**
|
||||
1. Navigate to Billing Settings
|
||||
2. Wait for the billing chart/data to finish loading
|
||||
- **Expected:**
|
||||
- Billing heading and subheading are displayed
|
||||
- Usage/cost table is visible with columns: Unit, Data Ingested, Price per Unit, Cost (Billing period to date)
|
||||
- "Download CSV" and "Manage Billing" buttons are present and enabled after loading
|
||||
- Test clicking "Download CSV" and "Manage Billing" for expected behavior (e.g., file download, navigation, or modal)
|
||||
|
||||
> Note: If these features are expected to trigger specific flows, document the observed behavior for each button.
|
||||
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
# Custom Domain E2E Scenarios (Updated)
|
||||
|
||||
## 1. Add or Update Custom Domain
|
||||
|
||||
- **Precondition:** User is admin
|
||||
- **Steps:**
|
||||
1. Click 'Customize team’s URL' button
|
||||
2. In the 'Customize your team’s URL' dialog, enter the preferred subdomain
|
||||
3. Click 'Apply Changes'
|
||||
- **Expected:** Domain is set/updated for the team (UI feedback/confirmation should be checked)
|
||||
|
||||
## 2. Verify Domain Ownership
|
||||
|
||||
- **Note:** No explicit 'Verify' button or flow is present in the current UI. If verification is required, it may be handled automatically or via support.
|
||||
|
||||
## 3. Remove a Custom Domain
|
||||
|
||||
- **Note:** No explicit 'Remove' button or flow is present in the current UI. The only available action is to update the subdomain.
|
||||
@@ -1,31 +0,0 @@
|
||||
# General Settings E2E Scenarios
|
||||
|
||||
## 1. View General Settings
|
||||
|
||||
- **Precondition:** User is logged in
|
||||
- **Steps:**
|
||||
1. Navigate to General Settings
|
||||
- **Expected:** General settings are displayed
|
||||
|
||||
## 2. Update Organization/Workspace Name
|
||||
|
||||
- **Precondition:** User is admin
|
||||
- **Steps:**
|
||||
1. Edit organization/workspace name
|
||||
2. Save changes
|
||||
- **Expected:** Name is updated and visible
|
||||
|
||||
## 3. Update Logo or Branding
|
||||
|
||||
- **Precondition:** User is admin
|
||||
- **Steps:**
|
||||
1. Upload new logo/branding
|
||||
2. Save changes
|
||||
- **Expected:** Branding is updated
|
||||
|
||||
## 4. View Version/Build Info
|
||||
|
||||
- **Precondition:** User is logged in
|
||||
- **Steps:**
|
||||
1. View version/build info section
|
||||
- **Expected:** Version/build info is displayed
|
||||
@@ -1,20 +0,0 @@
|
||||
# Ingestion E2E Scenarios (Updated)
|
||||
|
||||
## 1. View Ingestion Sources
|
||||
|
||||
- **Precondition:** User is admin
|
||||
- **Steps:**
|
||||
1. Navigate to the Integrations page
|
||||
- **Expected:** List of available data sources/integrations is displayed
|
||||
|
||||
## 2. Configure Ingestion Sources
|
||||
|
||||
- **Precondition:** User is admin
|
||||
- **Steps:**
|
||||
1. Click 'Configure' for a data source/integration
|
||||
2. Complete the configuration flow (modal or page, as available)
|
||||
- **Expected:** Source is configured (UI feedback/confirmation should be checked)
|
||||
|
||||
## 3. Disable/Enable Ingestion
|
||||
|
||||
- **Note:** No visible enable/disable toggle for ingestion sources in the current UI. Ingestion is managed via the Integrations configuration flows.
|
||||
@@ -1,51 +0,0 @@
|
||||
# Integrations E2E Scenarios (Updated)
|
||||
|
||||
## 1. View List of Available Integrations
|
||||
|
||||
- **Precondition:** User is logged in
|
||||
- **Steps:**
|
||||
1. Navigate to Integrations
|
||||
- **Expected:** List of integrations is displayed, each with a name, description, and 'Configure' button
|
||||
|
||||
## 2. Search Integrations by Name/Type
|
||||
|
||||
- **Precondition:** Integrations exist
|
||||
- **Steps:**
|
||||
1. Enter search/filter criteria in the 'Search for an integration...' box
|
||||
- **Expected:** Only matching integrations are shown
|
||||
|
||||
## 3. Connect a New Integration
|
||||
|
||||
- **Precondition:** User is admin
|
||||
- **Steps:**
|
||||
1. Click 'Configure' for an integration
|
||||
2. Complete the configuration flow (modal or page, as available)
|
||||
- **Expected:** Integration is connected/configured (UI feedback/confirmation should be checked)
|
||||
|
||||
## 4. Disconnect an Integration
|
||||
|
||||
- **Note:** No visible 'Disconnect' button in the main list. This may be available in the configuration flow for a connected integration.
|
||||
|
||||
## 5. Configure Integration Settings
|
||||
|
||||
- **Note:** Configuration is handled in the flow after clicking 'Configure' for an integration.
|
||||
|
||||
## 6. Test Integration Connection
|
||||
|
||||
- **Note:** No visible 'Test Connection' button in the main list. This may be available in the configuration flow.
|
||||
|
||||
## 7. View Integration Status/Logs
|
||||
|
||||
- **Note:** No visible status/logs section in the main list. This may be available in the configuration flow.
|
||||
|
||||
## 8. Filter Integrations by Category
|
||||
|
||||
- **Note:** No explicit category filter in the current UI, only a search box.
|
||||
|
||||
## 9. View Integration Documentation/Help
|
||||
|
||||
- **Note:** No visible 'Help/Docs' button in the main list. This may be available in the configuration flow.
|
||||
|
||||
## 10. Update Integration Configuration
|
||||
|
||||
- **Note:** Configuration is handled in the flow after clicking 'Configure' for an integration.
|
||||
@@ -1,19 +0,0 @@
|
||||
# Keyboard Shortcuts E2E Scenarios (Updated)
|
||||
|
||||
## 1. View Keyboard Shortcuts
|
||||
|
||||
- **Precondition:** User is logged in
|
||||
- **Steps:**
|
||||
1. Navigate to Keyboard Shortcuts
|
||||
- **Expected:** Shortcuts are displayed in categorized tables (Global, Logs Explorer, Query Builder, Dashboard)
|
||||
|
||||
## 2. Customize Keyboard Shortcuts (if supported)
|
||||
|
||||
- **Note:** Customization is not available in the current UI. Shortcuts are view-only.
|
||||
|
||||
## 3. Use Keyboard Shortcuts for Navigation/Actions
|
||||
|
||||
- **Precondition:** User is logged in
|
||||
- **Steps:**
|
||||
1. Use shortcut for navigation/action (e.g., shift+s for Services, cmd+enter for running query)
|
||||
- **Expected:** Navigation/action is performed as per shortcut
|
||||
@@ -1,49 +0,0 @@
|
||||
# Members & SSO E2E Scenarios (Updated)
|
||||
|
||||
## 1. Invite a New Member
|
||||
|
||||
- **Precondition:** User is admin
|
||||
- **Steps:**
|
||||
1. Click 'Invite Members' button
|
||||
2. In the 'Invite team members' dialog, enter email address, name (optional), and select role
|
||||
3. (Optional) Click 'Add another team member' to invite more
|
||||
4. Click 'Invite team members' to send invite(s)
|
||||
- **Expected:** Pending invite appears in the 'Pending Invites' table
|
||||
|
||||
## 2. Remove a Member
|
||||
|
||||
- **Precondition:** User is admin, member exists
|
||||
- **Steps:**
|
||||
1. In the 'Members' table, locate the member row
|
||||
2. Click 'Delete' in the Action column
|
||||
3. Confirm removal if prompted
|
||||
- **Expected:** Member is removed from the table
|
||||
|
||||
## 3. Update Member Roles
|
||||
|
||||
- **Precondition:** User is admin, member exists
|
||||
- **Steps:**
|
||||
1. In the 'Members' table, locate the member row
|
||||
2. Click 'Edit' in the Action column
|
||||
3. Change role in the edit dialog/modal
|
||||
4. Save changes
|
||||
- **Expected:** Member role is updated in the table
|
||||
|
||||
## 4. Configure SSO
|
||||
|
||||
- **Precondition:** User is admin
|
||||
- **Steps:**
|
||||
1. In the 'Authenticated Domains' section, locate the domain row
|
||||
2. Click 'Configure SSO' or 'Edit Google Auth' as available
|
||||
3. Complete SSO provider configuration in the modal/dialog
|
||||
4. Save settings
|
||||
- **Expected:** SSO is configured for the domain
|
||||
|
||||
## 5. Login via SSO
|
||||
|
||||
- **Precondition:** SSO is configured
|
||||
- **Steps:**
|
||||
1. Log out from the app
|
||||
2. On the login page, click 'Login with SSO'
|
||||
3. Complete SSO login flow
|
||||
- **Expected:** User is logged in via SSO
|
||||
@@ -1,39 +0,0 @@
|
||||
# Notification Channels E2E Scenarios (Updated)
|
||||
|
||||
## 1. Add a New Notification Channel
|
||||
|
||||
- **Precondition:** User is admin
|
||||
- **Steps:**
|
||||
1. Click 'New Alert Channel' button
|
||||
2. In the 'New Notification Channel' form, fill in required fields (Name, Type, Webhook URL, etc.)
|
||||
3. (Optional) Toggle 'Send resolved alerts'
|
||||
4. (Optional) Click 'Test' to send a test notification
|
||||
5. Click 'Save' to add the channel
|
||||
- **Expected:** Channel is added and listed in the table
|
||||
|
||||
## 2. Test Notification Channel
|
||||
|
||||
- **Precondition:** Channel is being created or edited
|
||||
- **Steps:**
|
||||
1. In the 'New Notification Channel' or 'Edit Notification Channel' form, click 'Test'
|
||||
- **Expected:** Test notification is sent (UI feedback/confirmation should be checked)
|
||||
|
||||
## 3. Remove a Notification Channel
|
||||
|
||||
- **Precondition:** Channel is added
|
||||
- **Steps:**
|
||||
1. In the table, locate the channel row
|
||||
2. Click 'Delete' in the Action column
|
||||
3. Confirm removal if prompted
|
||||
- **Expected:** Channel is removed from the table
|
||||
|
||||
## 4. Update Notification Channel Settings
|
||||
|
||||
- **Precondition:** Channel is added
|
||||
- **Steps:**
|
||||
1. In the table, locate the channel row
|
||||
2. Click 'Edit' in the Action column
|
||||
3. In the 'Edit Notification Channel' form, update fields as needed
|
||||
4. (Optional) Click 'Test' to send a test notification
|
||||
5. Click 'Save' to update the channel
|
||||
- **Expected:** Settings are updated
|
||||
@@ -1,199 +0,0 @@
|
||||
# SigNoz Test Plan Validation Report
|
||||
|
||||
This report documents the validation of the E2E test plan against the current live application using Playwright MCP. Each module is reviewed for coverage, gaps, and required updates.
|
||||
|
||||
---
|
||||
|
||||
## Home Module
|
||||
|
||||
- **Coverage:**
|
||||
- Widgets for logs, traces, metrics, dashboards, alerts, services, saved views, onboarding checklist
|
||||
- Quick access buttons: Explore Logs, Create dashboard, Create an alert
|
||||
- **Gaps/Updates:**
|
||||
- Add scenarios for checklist interactions (e.g., “I’ll do this later”, progress tracking)
|
||||
- Add scenarios for Saved Views and cross-module links
|
||||
- Add scenario for onboarding checklist completion
|
||||
|
||||
---
|
||||
|
||||
## Logs Module
|
||||
|
||||
- **Coverage:**
|
||||
- Explorer, Pipelines, Views tabs
|
||||
- Filtering by service, environment, severity, host, k8s, etc.
|
||||
- Search, save view, create alert, add to dashboard, export, view mode switching
|
||||
- **Gaps/Updates:**
|
||||
- Add scenario for quick filter customization
|
||||
- Add scenario for “Old Explorer” button
|
||||
- Add scenario for frequency chart toggle
|
||||
- Add scenario for “Stage & Run Query” workflow
|
||||
|
||||
---
|
||||
|
||||
## Traces Module
|
||||
|
||||
- **Coverage:**
|
||||
- Tabs: Explorer, Funnels, Views
|
||||
- Filtering by name, error status, duration, environment, function, service, RPC, status code, HTTP, trace ID, etc.
|
||||
- Search, save view, create alert, add to dashboard, export, view mode switching (List, Traces, Time Series, Table)
|
||||
- Pagination, quick filter customization, group by, aggregation
|
||||
- **Gaps/Updates:**
|
||||
- Add scenario for quick filter customization
|
||||
- Add scenario for “Stage & Run Query” workflow
|
||||
- Add scenario for all view modes (List, Traces, Time Series, Table)
|
||||
- Add scenario for group by/aggregation
|
||||
- Add scenario for trace detail navigation (clicking on trace row)
|
||||
- Add scenario for Funnels tab (create/edit/delete funnel)
|
||||
- Add scenario for Views tab (manage saved views)
|
||||
|
||||
---
|
||||
|
||||
## Metrics Module
|
||||
|
||||
- **Coverage:**
|
||||
- Tabs: Summary, Explorer, Views
|
||||
- Filtering by metric, type, unit, etc.
|
||||
- Search, save view, add to dashboard, export, view mode switching (chart, table, proportion view)
|
||||
- Pagination, group by, aggregation, custom queries
|
||||
- **Gaps/Updates:**
|
||||
- Add scenario for Proportion View in Summary
|
||||
- Add scenario for all view modes (chart, table, proportion)
|
||||
- Add scenario for group by/aggregation
|
||||
- Add scenario for custom queries in Explorer
|
||||
- Add scenario for Views tab (manage saved views)
|
||||
|
||||
---
|
||||
|
||||
## Dashboards Module
|
||||
|
||||
- **Coverage:**
|
||||
- List, search, and filter dashboards
|
||||
- Create new dashboard (button and template link)
|
||||
- Edit, delete, and view dashboard details
|
||||
- Add/edit/delete widgets (implied by dashboard detail)
|
||||
- Pagination through dashboards
|
||||
- **Gaps/Updates:**
|
||||
- Add scenario for browsing dashboard templates (external link)
|
||||
- Add scenario for requesting new template
|
||||
- Add scenario for dashboard owner and creation info
|
||||
- Add scenario for dashboard tags and filtering by tags
|
||||
- Add scenario for dashboard sharing (if available)
|
||||
- Add scenario for dashboard image/preview
|
||||
|
||||
---
|
||||
|
||||
## Messaging Queues Module
|
||||
|
||||
- **Coverage:**
|
||||
- Overview tab: queue metrics, filters (Service Name, Span Name, Msg System, Destination, Kind)
|
||||
- Search across all columns
|
||||
- Pagination of queue data
|
||||
- Sync and Share buttons
|
||||
- Tabs for Kafka and Celery
|
||||
- **Gaps/Updates:**
|
||||
- Add scenario for Kafka tab (detailed metrics, actions)
|
||||
- Add scenario for Celery tab (detailed metrics, actions)
|
||||
- Add scenario for filter combinations and edge cases
|
||||
- Add scenario for sharing queue data
|
||||
- Add scenario for time range selection
|
||||
|
||||
---
|
||||
|
||||
## External APIs Module
|
||||
|
||||
- **Coverage:**
|
||||
- Accessed via side navigation under MORE
|
||||
- Explorer tab: domain, endpoints, last used, rate, error %, avg. latency
|
||||
- Filters: Deployment Environment, Service Name, Rpc Method, Show IP addresses
|
||||
- Table pagination
|
||||
- Share and Stage & Run Query buttons
|
||||
- **Gaps/Updates:**
|
||||
- Add scenario for customizing quick filters
|
||||
- Add scenario for running and staging queries
|
||||
- Add scenario for sharing API data
|
||||
- Add scenario for edge cases in filters and table data
|
||||
|
||||
---
|
||||
|
||||
## Alerts Module
|
||||
|
||||
- **Coverage:**
|
||||
- Alert Rules tab: list, search, create (New Alert), edit, delete, enable/disable, severity, labels, actions
|
||||
- Triggered Alerts tab (visible in tablist)
|
||||
- Configuration tab (visible in tablist)
|
||||
- Table pagination
|
||||
- **Gaps/Updates:**
|
||||
- Add scenario for triggered alerts (view, acknowledge, resolve)
|
||||
- Add scenario for alert configuration (settings, integrations)
|
||||
- Add scenario for edge cases in alert creation and management
|
||||
- Add scenario for searching and filtering alerts
|
||||
|
||||
---
|
||||
|
||||
## Integrations Module
|
||||
|
||||
- **Coverage:**
|
||||
- Integrations tab: list, search, configure (e.g., AWS), request new integration
|
||||
- One-click setup for AWS monitoring
|
||||
- Request more integrations (form)
|
||||
- **Gaps/Updates:**
|
||||
- Add scenario for configuring integrations (step-by-step)
|
||||
- Add scenario for searching and filtering integrations
|
||||
- Add scenario for requesting new integrations
|
||||
- Add scenario for edge cases (e.g., failed configuration)
|
||||
|
||||
---
|
||||
|
||||
## Exceptions Module
|
||||
|
||||
- **Coverage:**
|
||||
- All Exceptions: list, search, filter (Deployment Environment, Service Name, Host Name, K8s Cluster/Deployment/Namespace, Net Peer Name)
|
||||
- Table: Exception Type, Error Message, Count, Last Seen, First Seen, Application
|
||||
- Pagination
|
||||
- Exception detail links
|
||||
- Share and Stage & Run Query buttons
|
||||
- **Gaps/Updates:**
|
||||
- Add scenario for exception detail view
|
||||
- Add scenario for advanced filtering and edge cases
|
||||
- Add scenario for sharing and running queries
|
||||
- Add scenario for error grouping and navigation
|
||||
|
||||
---
|
||||
|
||||
## Service Map Module
|
||||
|
||||
- **Coverage:**
|
||||
- Service Map visualization (main graph)
|
||||
- Filters: environment, resource attributes
|
||||
- Time range selection
|
||||
- Sync and Share buttons
|
||||
- **Gaps/Updates:**
|
||||
- Add scenario for interacting with the map (zoom, pan, select service)
|
||||
- Add scenario for filtering and edge cases
|
||||
- Add scenario for sharing the map
|
||||
- Add scenario for time range and environment combinations
|
||||
|
||||
---
|
||||
|
||||
## Billing Module
|
||||
|
||||
- **Coverage:**
|
||||
- Billing overview: cost monitoring, invoices, CSV download (disabled), manage billing (disabled)
|
||||
- Teams Cloud section
|
||||
- Billing table: Unit, Data Ingested, Price per Unit, Cost (Billing period to date)
|
||||
- **Gaps/Updates:**
|
||||
- Add scenario for invoice download and management (when enabled)
|
||||
- Add scenario for cost monitoring and edge cases
|
||||
- Add scenario for billing table data validation
|
||||
- Add scenario for permissions and access control
|
||||
|
||||
---
|
||||
|
||||
## Usage Explorer Module
|
||||
|
||||
- **Status:**
|
||||
- Not accessible in the current environment. Removing from test plan flows.
|
||||
|
||||
---
|
||||
|
||||
## [Next modules will be filled as validation proceeds]
|
||||
@@ -1,42 +0,0 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { ensureLoggedIn } from '../../../utils/login.util';
|
||||
|
||||
test('Account Settings - View and Assert Static Controls', async ({ page }) => {
|
||||
await ensureLoggedIn(page);
|
||||
|
||||
// 1. Open the sidebar settings menu using data-testid
|
||||
await page.getByTestId('settings-nav-item').click();
|
||||
|
||||
// 2. Click Account Settings in the dropdown (by role/name or data-testid if available)
|
||||
await page.getByRole('menuitem', { name: 'Account Settings' }).click();
|
||||
|
||||
// Assert the main tabpanel/heading (confirmed by DOM)
|
||||
await expect(page.getByTestId('settings-page-title')).toBeVisible();
|
||||
|
||||
// Assert General section and controls (confirmed by DOM)
|
||||
await expect(
|
||||
page.getByLabel('My Settings').getByText('General'),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText('Manage your account settings.')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Update name' })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Reset password' }),
|
||||
).toBeVisible();
|
||||
|
||||
// Assert User Preferences section and controls (confirmed by DOM)
|
||||
await expect(page.getByText('User Preferences')).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Tailor the SigNoz console to work according to your needs.'),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText('Select your theme')).toBeVisible();
|
||||
|
||||
const themeSelector = page.getByTestId('theme-selector');
|
||||
|
||||
await expect(themeSelector.getByText('Dark')).toBeVisible();
|
||||
await expect(themeSelector.getByText('Light')).toBeVisible();
|
||||
await expect(themeSelector.getByText('System')).toBeVisible();
|
||||
|
||||
await expect(page.getByTestId('timezone-adaptation-switch')).toBeVisible();
|
||||
await expect(page.getByTestId('side-nav-pinned-switch')).toBeVisible();
|
||||
});
|
||||
@@ -1,42 +0,0 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { ensureLoggedIn } from '../../../utils/login.util';
|
||||
|
||||
test('API Keys Settings - View and Interact', async ({ page }) => {
|
||||
await ensureLoggedIn(page);
|
||||
|
||||
// 1. Open the sidebar settings menu using data-testid
|
||||
await page.getByTestId('settings-nav-item').click();
|
||||
|
||||
// 2. Click Account Settings in the dropdown (by role/name or data-testid if available)
|
||||
await page.getByRole('menuitem', { name: 'Account Settings' }).click();
|
||||
|
||||
// Assert the main tabpanel/heading (confirmed by DOM)
|
||||
await expect(page.getByTestId('settings-page-title')).toBeVisible();
|
||||
|
||||
// Focus on the settings page sidenav
|
||||
await page.getByTestId('settings-page-sidenav').focus();
|
||||
|
||||
// Click API Keys tab in the settings sidebar (by data-testid)
|
||||
await page.getByTestId('api-keys').click();
|
||||
|
||||
// Assert heading and subheading
|
||||
await expect(page.getByRole('heading', { name: 'API Keys' })).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Create and manage API keys for the SigNoz API'),
|
||||
).toBeVisible();
|
||||
|
||||
// Assert presence of New Key button
|
||||
const newKeyBtn = page.getByRole('button', { name: 'New Key' });
|
||||
await expect(newKeyBtn).toBeVisible();
|
||||
|
||||
// Assert table columns
|
||||
await expect(page.getByText('Last used').first()).toBeVisible();
|
||||
await expect(page.getByText('Expired').first()).toBeVisible();
|
||||
|
||||
// Assert at least one API key row with action buttons
|
||||
// Select the first action cell's first button (icon button)
|
||||
const firstActionCell = page.locator('table tr').nth(1).locator('td').last();
|
||||
const deleteBtn = firstActionCell.locator('button').first();
|
||||
await expect(deleteBtn).toBeVisible();
|
||||
});
|
||||
@@ -1,71 +0,0 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { ensureLoggedIn } from '../../../utils/login.util';
|
||||
|
||||
// E2E: Billing Settings - View Billing Information and Button Actions
|
||||
|
||||
test('View Billing Information and Button Actions', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
// Ensure user is logged in
|
||||
await ensureLoggedIn(page);
|
||||
|
||||
// 1. Open the sidebar settings menu using data-testid
|
||||
await page.getByTestId('settings-nav-item').click();
|
||||
|
||||
// 2. Click Account Settings in the dropdown (by role/name or data-testid if available)
|
||||
await page.getByRole('menuitem', { name: 'Account Settings' }).click();
|
||||
|
||||
// Assert the main tabpanel/heading (confirmed by DOM)
|
||||
await expect(page.getByTestId('settings-page-title')).toBeVisible();
|
||||
|
||||
// Focus on the settings page sidenav
|
||||
await page.getByTestId('settings-page-sidenav').focus();
|
||||
|
||||
// Click Billing tab in the settings sidebar (by data-testid)
|
||||
await page.getByTestId('billing').click();
|
||||
|
||||
// Wait for billing chart/data to finish loading
|
||||
await page.getByText('loading').first().waitFor({ state: 'hidden' });
|
||||
|
||||
// Assert visibility of subheading (unique)
|
||||
await expect(
|
||||
page.getByText(
|
||||
'Manage your billing information, invoices, and monitor costs.',
|
||||
),
|
||||
).toBeVisible();
|
||||
// Assert visibility of Teams Cloud heading
|
||||
await expect(page.getByRole('heading', { name: 'Teams Cloud' })).toBeVisible();
|
||||
|
||||
// Assert presence of summary and detailed tables
|
||||
await expect(page.getByText('TOTAL SPENT')).toBeVisible();
|
||||
await expect(page.getByText('Data Ingested')).toBeVisible();
|
||||
await expect(page.getByText('Price per Unit')).toBeVisible();
|
||||
await expect(page.getByText('Cost (Billing period to date)')).toBeVisible();
|
||||
|
||||
// Assert presence of alert and note
|
||||
await expect(
|
||||
page.getByText('Your current billing period is from', { exact: false }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Billing metrics are updated once every 24 hours.'),
|
||||
).toBeVisible();
|
||||
|
||||
// Test Download CSV button
|
||||
const [download] = await Promise.all([
|
||||
page.waitForEvent('download'),
|
||||
page.getByRole('button', { name: 'cloud-download Download CSV' }).click(),
|
||||
]);
|
||||
// Optionally, check download file name
|
||||
expect(download.suggestedFilename()).toContain('billing_usage');
|
||||
|
||||
// Test Manage Billing button (opens Stripe in new tab)
|
||||
const [newPage] = await Promise.all([
|
||||
context.waitForEvent('page'),
|
||||
page.getByTestId('header-billing-button').click(),
|
||||
]);
|
||||
await newPage.waitForLoadState();
|
||||
expect(newPage.url()).toContain('stripe.com');
|
||||
await newPage.close();
|
||||
});
|
||||
@@ -1,52 +0,0 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { ensureLoggedIn } from '../../../utils/login.util';
|
||||
|
||||
test('Custom Domain Settings - View and Interact', async ({ page }) => {
|
||||
await ensureLoggedIn(page);
|
||||
|
||||
// 1. Open the sidebar settings menu using data-testid
|
||||
await page.getByTestId('settings-nav-item').click();
|
||||
|
||||
// 2. Click Account Settings in the dropdown (by role/name or data-testid if available)
|
||||
await page.getByRole('menuitem', { name: 'Account Settings' }).click();
|
||||
|
||||
// Assert the main tabpanel/heading (confirmed by DOM)
|
||||
await expect(page.getByTestId('settings-page-title')).toBeVisible();
|
||||
|
||||
// Focus on the settings page sidenav
|
||||
await page.getByTestId('settings-page-sidenav').focus();
|
||||
|
||||
// Click Custom Domain tab in the settings sidebar (by data-testid)
|
||||
await page.getByTestId('custom-domain').click();
|
||||
|
||||
// Wait for custom domain chart/data to finish loading
|
||||
await page.getByText('loading').first().waitFor({ state: 'hidden' });
|
||||
|
||||
// Assert heading and subheading
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Custom Domain Settings' }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Personalize your workspace domain effortlessly.'),
|
||||
).toBeVisible();
|
||||
|
||||
// Assert presence of Customize team’s URL button
|
||||
const customizeBtn = page.getByRole('button', {
|
||||
name: 'Customize team’s URL',
|
||||
});
|
||||
await expect(customizeBtn).toBeVisible();
|
||||
await customizeBtn.click();
|
||||
|
||||
// Assert modal/dialog fields and buttons
|
||||
await expect(
|
||||
page.getByRole('dialog', { name: 'Customize your team’s URL' }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByLabel('Team’s URL subdomain')).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Apply Changes' }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Close' })).toBeVisible();
|
||||
// Close the modal
|
||||
await page.getByRole('button', { name: 'Close' }).click();
|
||||
});
|
||||
@@ -1,32 +0,0 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { ensureLoggedIn } from '../../../utils/login.util';
|
||||
|
||||
test('View General Settings', async ({ page }) => {
|
||||
await ensureLoggedIn(page);
|
||||
|
||||
// 1. Open the sidebar settings menu using data-testid
|
||||
await page.getByTestId('settings-nav-item').click();
|
||||
|
||||
// 2. Click Account Settings in the dropdown (by role/name or data-testid if available)
|
||||
await page.getByRole('menuitem', { name: 'Account Settings' }).click();
|
||||
|
||||
// Assert the main tabpanel/heading (confirmed by DOM)
|
||||
await expect(page.getByTestId('settings-page-title')).toBeVisible();
|
||||
|
||||
// Focus on the settings page sidenav
|
||||
await page.getByTestId('settings-page-sidenav').focus();
|
||||
|
||||
// Click General tab in the settings sidebar (by data-testid)
|
||||
await page.getByTestId('general').click();
|
||||
|
||||
// Wait for General tab to be visible
|
||||
await page.getByRole('tabpanel', { name: 'General' }).waitFor();
|
||||
|
||||
// Assert visibility of definitive/static elements
|
||||
await expect(page.getByRole('heading', { name: 'Metrics' })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Traces' })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Logs' })).toBeVisible();
|
||||
await expect(page.getByText('Please')).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'email us' })).toBeVisible();
|
||||
});
|
||||
@@ -1,48 +0,0 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { ensureLoggedIn } from '../../../utils/login.util';
|
||||
|
||||
test('Ingestion Settings - View and Interact', async ({ page }) => {
|
||||
await ensureLoggedIn(page);
|
||||
|
||||
// 1. Open the sidebar settings menu using data-testid
|
||||
await page.getByTestId('settings-nav-item').click();
|
||||
|
||||
// 2. Click Account Settings in the dropdown (by role/name or data-testid if available)
|
||||
await page.getByRole('menuitem', { name: 'Account Settings' }).click();
|
||||
|
||||
// Assert the main tabpanel/heading (confirmed by DOM)
|
||||
await expect(page.getByTestId('settings-page-title')).toBeVisible();
|
||||
|
||||
// Focus on the settings page sidenav
|
||||
await page.getByTestId('settings-page-sidenav').focus();
|
||||
|
||||
// Click Ingestion tab in the settings sidebar (by data-testid)
|
||||
await page.getByTestId('ingestion').click();
|
||||
|
||||
// Assert heading and subheading (Integrations page)
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Integrations' }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Manage Integrations for this workspace'),
|
||||
).toBeVisible();
|
||||
|
||||
// Assert presence of search box
|
||||
await expect(
|
||||
page.getByPlaceholder('Search for an integration...'),
|
||||
).toBeVisible();
|
||||
|
||||
// Assert at least one data source with Configure button
|
||||
const configureBtn = page.getByRole('button', { name: 'Configure' }).first();
|
||||
await expect(configureBtn).toBeVisible();
|
||||
|
||||
// Assert Request more integrations section
|
||||
await expect(
|
||||
page.getByText(
|
||||
"Can't find what you’re looking for? Request more integrations",
|
||||
),
|
||||
).toBeVisible();
|
||||
await expect(page.getByPlaceholder('Enter integration name...')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Submit' })).toBeVisible();
|
||||
});
|
||||
@@ -1,48 +0,0 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { ensureLoggedIn } from '../../../utils/login.util';
|
||||
|
||||
test('Integrations Settings - View and Interact', async ({ page }) => {
|
||||
await ensureLoggedIn(page);
|
||||
|
||||
// 1. Open the sidebar settings menu using data-testid
|
||||
await page.getByTestId('settings-nav-item').click();
|
||||
|
||||
// 2. Click Account Settings in the dropdown (by role/name or data-testid if available)
|
||||
await page.getByRole('menuitem', { name: 'Account Settings' }).click();
|
||||
|
||||
// Assert the main tabpanel/heading (confirmed by DOM)
|
||||
await expect(page.getByTestId('settings-page-title')).toBeVisible();
|
||||
|
||||
// Focus on the settings page sidenav
|
||||
await page.getByTestId('settings-page-sidenav').focus();
|
||||
|
||||
// Click Integrations tab in the settings sidebar (by data-testid)
|
||||
await page.getByTestId('integrations').click();
|
||||
|
||||
// Assert heading and subheading
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Integrations' }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Manage Integrations for this workspace'),
|
||||
).toBeVisible();
|
||||
|
||||
// Assert presence of search box
|
||||
await expect(
|
||||
page.getByPlaceholder('Search for an integration...'),
|
||||
).toBeVisible();
|
||||
|
||||
// Assert at least one integration with Configure button
|
||||
const configureBtn = page.getByRole('button', { name: 'Configure' }).first();
|
||||
await expect(configureBtn).toBeVisible();
|
||||
|
||||
// Assert Request more integrations section
|
||||
await expect(
|
||||
page.getByText(
|
||||
"Can't find what you’re looking for? Request more integrations",
|
||||
),
|
||||
).toBeVisible();
|
||||
await expect(page.getByPlaceholder('Enter integration name...')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Submit' })).toBeVisible();
|
||||
});
|
||||
@@ -1,56 +0,0 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { ensureLoggedIn } from '../../../utils/login.util';
|
||||
|
||||
test('Members & SSO Settings - View and Interact', async ({ page }) => {
|
||||
await ensureLoggedIn(page);
|
||||
|
||||
// 1. Open the sidebar settings menu using data-testid
|
||||
await page.getByTestId('settings-nav-item').click();
|
||||
|
||||
// 2. Click Account Settings in the dropdown (by role/name or data-testid if available)
|
||||
await page.getByRole('menuitem', { name: 'Account Settings' }).click();
|
||||
|
||||
// Assert the main tabpanel/heading (confirmed by DOM)
|
||||
await expect(page.getByTestId('settings-page-title')).toBeVisible();
|
||||
|
||||
// Focus on the settings page sidenav
|
||||
await page.getByTestId('settings-page-sidenav').focus();
|
||||
|
||||
// Click Members & SSO tab in the settings sidebar (by data-testid)
|
||||
await page.getByTestId('members-sso').click();
|
||||
|
||||
// Assert headings and tables
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /Members \(\d+\)/ }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /Pending Invites \(\d+\)/ }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Authenticated Domains' }),
|
||||
).toBeVisible();
|
||||
|
||||
// Assert Invite Members button is visible and clickable
|
||||
const inviteBtn = page.getByRole('button', { name: /Invite Members/ });
|
||||
await expect(inviteBtn).toBeVisible();
|
||||
await inviteBtn.click();
|
||||
// Assert Invite Members modal/dialog appears (modal title is unique)
|
||||
await expect(page.getByText('Invite team members').first()).toBeVisible();
|
||||
// Close the modal (use unique 'Close' button)
|
||||
await page.getByRole('button', { name: 'Close' }).click();
|
||||
|
||||
// Assert Edit and Delete buttons are present for at least one member
|
||||
const editBtn = page.getByRole('button', { name: /Edit/ }).first();
|
||||
const deleteBtn = page.getByRole('button', { name: /Delete/ }).first();
|
||||
await expect(editBtn).toBeVisible();
|
||||
await expect(deleteBtn).toBeVisible();
|
||||
|
||||
// Assert Add Domains button is visible
|
||||
await expect(page.getByRole('button', { name: /Add Domains/ })).toBeVisible();
|
||||
// Assert Configure SSO or Edit Google Auth button is visible for at least one domain
|
||||
const ssoBtn = page
|
||||
.getByRole('button', { name: /Configure SSO|Edit Google Auth/ })
|
||||
.first();
|
||||
await expect(ssoBtn).toBeVisible();
|
||||
});
|
||||
@@ -1,57 +0,0 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { ensureLoggedIn } from '../../../utils/login.util';
|
||||
|
||||
test('Notification Channels Settings - View and Interact', async ({ page }) => {
|
||||
await ensureLoggedIn(page);
|
||||
|
||||
// 1. Open the sidebar settings menu using data-testid
|
||||
await page.getByTestId('settings-nav-item').click();
|
||||
|
||||
// 2. Click Account Settings in the dropdown (by role/name or data-testid if available)
|
||||
await page.getByRole('menuitem', { name: 'Account Settings' }).click();
|
||||
|
||||
// Assert the main tabpanel/heading (confirmed by DOM)
|
||||
await expect(page.getByTestId('settings-page-title')).toBeVisible();
|
||||
|
||||
// Focus on the settings page sidenav
|
||||
await page.getByTestId('settings-page-sidenav').focus();
|
||||
|
||||
// Click Notification Channels tab in the settings sidebar (by data-testid)
|
||||
await page.getByTestId('notification-channels').click();
|
||||
|
||||
// Wait for loading to finish
|
||||
await page.getByText('loading').first().waitFor({ state: 'hidden' });
|
||||
|
||||
// Assert presence of New Alert Channel button
|
||||
const newChannelBtn = page.getByRole('button', { name: /New Alert Channel/ });
|
||||
await expect(newChannelBtn).toBeVisible();
|
||||
|
||||
// Assert table columns
|
||||
await expect(page.getByText('Name')).toBeVisible();
|
||||
await expect(page.getByText('Type')).toBeVisible();
|
||||
await expect(page.getByText('Action')).toBeVisible();
|
||||
|
||||
// Click New Alert Channel and assert modal fields/buttons
|
||||
await newChannelBtn.click();
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'New Notification Channel' }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByLabel('Name')).toBeVisible();
|
||||
await expect(page.getByLabel('Type')).toBeVisible();
|
||||
await expect(page.getByLabel('Webhook URL')).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('switch', { name: 'Send resolved alerts' }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Test' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Back' })).toBeVisible();
|
||||
// Close modal
|
||||
await page.getByRole('button', { name: 'Back' }).click();
|
||||
|
||||
// Assert Edit and Delete buttons for at least one channel
|
||||
const editBtn = page.getByRole('button', { name: 'Edit' }).first();
|
||||
const deleteBtn = page.getByRole('button', { name: 'Delete' }).first();
|
||||
await expect(editBtn).toBeVisible();
|
||||
await expect(deleteBtn).toBeVisible();
|
||||
});
|
||||
@@ -1,35 +0,0 @@
|
||||
import { Page } from '@playwright/test';
|
||||
|
||||
// Read credentials from environment variables
|
||||
const username = process.env.LOGIN_USERNAME;
|
||||
const password = process.env.LOGIN_PASSWORD;
|
||||
const baseURL = process.env.BASE_URL;
|
||||
|
||||
/**
|
||||
* Ensures the user is logged in. If not, performs the login steps.
|
||||
* Follows the MCP process step-by-step.
|
||||
*/
|
||||
export async function ensureLoggedIn(page: Page): Promise<void> {
|
||||
// if already in home page, return
|
||||
if (await page.url().includes('/home')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!username || !password) {
|
||||
throw new Error(
|
||||
'E2E_EMAIL and E2E_PASSWORD environment variables must be set.',
|
||||
);
|
||||
}
|
||||
|
||||
await page.goto(`${baseURL}/login`);
|
||||
await page.getByTestId('email').click();
|
||||
await page.getByTestId('email').fill(username);
|
||||
await page.getByTestId('initiate_login').click();
|
||||
await page.getByTestId('password').click();
|
||||
await page.getByTestId('password').fill(password);
|
||||
await page.getByRole('button', { name: 'Login' }).click();
|
||||
|
||||
await page
|
||||
.getByText('Hello there, Welcome to your')
|
||||
.waitFor({ state: 'visible' });
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<base href="[[.BaseHref]]" />
|
||||
<meta
|
||||
http-equiv="Cache-Control"
|
||||
content="no-cache, no-store, must-revalidate, max-age: 0"
|
||||
@@ -59,7 +60,7 @@
|
||||
<meta data-react-helmet="true" name="docusaurus_locale" content="en" />
|
||||
<meta data-react-helmet="true" name="docusaurus_tag" content="default" />
|
||||
<meta name="robots" content="noindex" />
|
||||
<link data-react-helmet="true" rel="shortcut icon" href="/favicon.ico" />
|
||||
<link data-react-helmet="true" rel="shortcut icon" href="favicon.ico" />
|
||||
</head>
|
||||
<body data-theme="default">
|
||||
<script>
|
||||
@@ -136,7 +137,7 @@
|
||||
})(document, 'script');
|
||||
}
|
||||
</script>
|
||||
<link rel="stylesheet" href="/css/uPlot.min.css" />
|
||||
<link rel="stylesheet" href="css/uPlot.min.css" />
|
||||
<script type="module" src="./src/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -44,7 +44,6 @@
|
||||
"@mdx-js/loader": "2.3.0",
|
||||
"@mdx-js/react": "2.3.0",
|
||||
"@monaco-editor/react": "^4.3.1",
|
||||
"@playwright/test": "1.55.1",
|
||||
"@radix-ui/react-tabs": "1.0.4",
|
||||
"@radix-ui/react-tooltip": "1.0.7",
|
||||
"@sentry/react": "8.41.0",
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
import dotenv from 'dotenv';
|
||||
import path from 'path';
|
||||
|
||||
// Read from ".env" file.
|
||||
dotenv.config({ path: path.resolve(__dirname, '.env') });
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
* https://github.com/motdotla/dotenv
|
||||
*/
|
||||
// import dotenv from 'dotenv';
|
||||
// import path from 'path';
|
||||
// dotenv.config({ path: path.resolve(__dirname, '.env') });
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './e2e/tests',
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: true,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: !!process.env.CI,
|
||||
/* Retry on CI only */
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
/* Run tests in parallel even in CI - optimized for GitHub Actions free tier */
|
||||
workers: process.env.CI ? 2 : undefined,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: 'html',
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
baseURL:
|
||||
process.env.SIGNOZ_E2E_BASE_URL || 'https://app.us.staging.signoz.cloud',
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: 'on-first-retry',
|
||||
colorScheme: 'dark',
|
||||
locale: 'en-US',
|
||||
viewport: { width: 1280, height: 720 },
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: {
|
||||
launchOptions: { args: ['--start-maximized'] },
|
||||
viewport: null,
|
||||
colorScheme: 'dark',
|
||||
locale: 'en-US',
|
||||
baseURL: 'https://app.us.staging.signoz.cloud',
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'firefox',
|
||||
use: { ...devices['Desktop Firefox'] },
|
||||
},
|
||||
|
||||
{
|
||||
name: 'webkit',
|
||||
use: { ...devices['Desktop Safari'] },
|
||||
},
|
||||
|
||||
/* Test against mobile viewports. */
|
||||
// {
|
||||
// name: 'Mobile Chrome',
|
||||
// use: { ...devices['Pixel 5'] },
|
||||
// },
|
||||
// {
|
||||
// name: 'Mobile Safari',
|
||||
// use: { ...devices['iPhone 12'] },
|
||||
// },
|
||||
|
||||
/* Test against branded browsers. */
|
||||
// {
|
||||
// name: 'Microsoft Edge',
|
||||
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
|
||||
// },
|
||||
// {
|
||||
// name: 'Google Chrome',
|
||||
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
|
||||
// },
|
||||
],
|
||||
|
||||
/* Run your local dev server before starting the tests */
|
||||
// webServer: {
|
||||
// command: 'npm run start',
|
||||
// url: 'http://localhost:3000',
|
||||
// reuseExistingServer: !process.env.CI,
|
||||
// },
|
||||
});
|
||||
152
frontend/plugins/rules/no-raw-absolute-path.mjs
Normal file
152
frontend/plugins/rules/no-raw-absolute-path.mjs
Normal file
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Rule: no-raw-absolute-path
|
||||
*
|
||||
* Catches patterns that break at runtime when the app is served from a
|
||||
* sub-path (e.g. /signoz/):
|
||||
*
|
||||
* 1. window.open(path, '_blank')
|
||||
* → use openInNewTab(path) which calls withBasePath internally
|
||||
*
|
||||
* 2. window.location.origin + path / `${window.location.origin}${path}`
|
||||
* → use getAbsoluteUrl(path)
|
||||
*
|
||||
* 3. frontendBaseUrl: window.location.origin (bare origin usage)
|
||||
* → use getBaseUrl() to include the base path
|
||||
*
|
||||
* 4. window.location.href = path
|
||||
* → use withBasePath(path) or navigate() for internal navigation
|
||||
*
|
||||
* External URLs (first arg starts with "http") are explicitly allowed.
|
||||
*/
|
||||
|
||||
function isOriginAccess(node) {
|
||||
return (
|
||||
node.type === 'MemberExpression' &&
|
||||
!node.computed &&
|
||||
node.property.name === 'origin' &&
|
||||
node.object.type === 'MemberExpression' &&
|
||||
!node.object.computed &&
|
||||
node.object.property.name === 'location' &&
|
||||
node.object.object.type === 'Identifier' &&
|
||||
node.object.object.name === 'window'
|
||||
);
|
||||
}
|
||||
|
||||
function isHrefAccess(node) {
|
||||
return (
|
||||
node.type === 'MemberExpression' &&
|
||||
!node.computed &&
|
||||
node.property.name === 'href' &&
|
||||
node.object.type === 'MemberExpression' &&
|
||||
!node.object.computed &&
|
||||
node.object.property.name === 'location' &&
|
||||
node.object.object.type === 'Identifier' &&
|
||||
node.object.object.name === 'window'
|
||||
);
|
||||
}
|
||||
|
||||
function isExternalUrl(node) {
|
||||
if (node.type === 'Literal' && typeof node.value === 'string') {
|
||||
return node.value.startsWith('http://') || node.value.startsWith('https://');
|
||||
}
|
||||
if (node.type === 'TemplateLiteral' && node.quasis.length > 0) {
|
||||
const raw = node.quasis[0].value.raw;
|
||||
return raw.startsWith('http://') || raw.startsWith('https://');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
// window.open(withBasePath(x)) and window.open(getAbsoluteUrl(x)) are already safe.
|
||||
function isSafeHelperCall(node) {
|
||||
return (
|
||||
node.type === 'CallExpression' &&
|
||||
node.callee.type === 'Identifier' &&
|
||||
(node.callee.name === 'withBasePath' || node.callee.name === 'getAbsoluteUrl')
|
||||
);
|
||||
}
|
||||
|
||||
export default {
|
||||
meta: {
|
||||
type: 'suggestion',
|
||||
docs: {
|
||||
description:
|
||||
'Disallow raw window.open and origin-concatenation patterns that miss the runtime base path',
|
||||
category: 'Base Path Safety',
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
windowOpen:
|
||||
'Use openInNewTab(path) instead of window.open(path, "_blank") — openInNewTab prepends the base path automatically.',
|
||||
originConcat:
|
||||
'Use getAbsoluteUrl(path) instead of window.location.origin + path — getAbsoluteUrl prepends the base path automatically.',
|
||||
originDirect:
|
||||
'Use getBaseUrl() instead of window.location.origin — getBaseUrl includes the base path.',
|
||||
hrefAssign:
|
||||
'Use withBasePath(path) or navigate() instead of window.location.href = path — ensures the base path is included.',
|
||||
},
|
||||
},
|
||||
|
||||
create(context) {
|
||||
return {
|
||||
// window.open(path, ...) — allow only external first-arg URLs
|
||||
CallExpression(node) {
|
||||
const { callee, arguments: args } = node;
|
||||
if (
|
||||
callee.type !== 'MemberExpression' ||
|
||||
callee.object.type !== 'Identifier' ||
|
||||
callee.object.name !== 'window' ||
|
||||
callee.property.name !== 'open'
|
||||
)
|
||||
{return;}
|
||||
if (args.length === 0) {return;}
|
||||
if (isExternalUrl(args[0])) {return;}
|
||||
if (isSafeHelperCall(args[0])) {return;}
|
||||
|
||||
context.report({ node, messageId: 'windowOpen' });
|
||||
},
|
||||
|
||||
// window.location.origin + path
|
||||
BinaryExpression(node) {
|
||||
if (node.operator !== '+') {return;}
|
||||
if (isOriginAccess(node.left) || isOriginAccess(node.right)) {
|
||||
context.report({ node, messageId: 'originConcat' });
|
||||
}
|
||||
},
|
||||
|
||||
// `${window.location.origin}${path}`
|
||||
TemplateLiteral(node) {
|
||||
if (node.expressions.some(isOriginAccess)) {
|
||||
context.report({ node, messageId: 'originConcat' });
|
||||
}
|
||||
},
|
||||
|
||||
// window.location.origin used directly (not in concatenation)
|
||||
// Catches: frontendBaseUrl: window.location.origin
|
||||
MemberExpression(node) {
|
||||
if (!isOriginAccess(node)) {return;}
|
||||
|
||||
const parent = node.parent;
|
||||
// Skip if parent is BinaryExpression with + (handled by BinaryExpression visitor)
|
||||
if (parent.type === 'BinaryExpression' && parent.operator === '+') {return;}
|
||||
// Skip if inside TemplateLiteral (handled by TemplateLiteral visitor)
|
||||
if (parent.type === 'TemplateLiteral') {return;}
|
||||
|
||||
context.report({ node, messageId: 'originDirect' });
|
||||
},
|
||||
|
||||
// window.location.href = path
|
||||
AssignmentExpression(node) {
|
||||
if (node.operator !== '=') {return;}
|
||||
if (!isHrefAccess(node.left)) {return;}
|
||||
|
||||
// Allow external URLs
|
||||
if (isExternalUrl(node.right)) {return;}
|
||||
// Allow safe helper calls
|
||||
if (isSafeHelperCall(node.right)) {return;}
|
||||
|
||||
context.report({ node, messageId: 'hrefAssign' });
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -8,6 +8,7 @@
|
||||
import noZustandGetStateInHooks from './rules/no-zustand-getstate-in-hooks.mjs';
|
||||
import noNavigatorClipboard from './rules/no-navigator-clipboard.mjs';
|
||||
import noUnsupportedAssetPattern from './rules/no-unsupported-asset-pattern.mjs';
|
||||
import noRawAbsolutePath from './rules/no-raw-absolute-path.mjs';
|
||||
|
||||
export default {
|
||||
meta: {
|
||||
@@ -17,5 +18,6 @@ export default {
|
||||
'no-zustand-getstate-in-hooks': noZustandGetStateInHooks,
|
||||
'no-navigator-clipboard': noNavigatorClipboard,
|
||||
'no-unsupported-asset-pattern': noUnsupportedAssetPattern,
|
||||
'no-raw-absolute-path': noRawAbsolutePath,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
RULE: All test code for this repo must be generated by following the step-by-step Playwright MCP process as described below.
|
||||
|
||||
- You are a playwright test generator.
|
||||
- You are given a scenario and you need to generate a playwright test for it.
|
||||
- Use login util if not logged in.
|
||||
- DO NOT generate test code based on the scenario alone.
|
||||
- DO run steps one by one using the tools provided by the Playwright MCP.
|
||||
- Only after all steps are completed, emit a Playwright TypeScript test that uses @playwright/test based on message history
|
||||
- Gather correct selectors before writing the test
|
||||
- DO NOT valiate for dynamic content in the tests, only validate for the correctness with meta data
|
||||
- Always inspect the DOM at each navigation or interaction step to determine the correct selector for the next action. Do not assume selectors, confirm via inspection before proceeding.
|
||||
- Assert visibility of definitive/static elements in the UI (such as labels, headings, or section titles) rather than dynamic values or content that may change between runs.
|
||||
- Save generated test file in the tests directory
|
||||
- Execute the test file and iterate until the test passes
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { initReactI18next } from 'react-i18next';
|
||||
import i18n from 'i18next';
|
||||
import LanguageDetector from 'i18next-browser-languagedetector';
|
||||
import Backend from 'i18next-http-backend';
|
||||
import { getBasePath } from 'utils/basePath';
|
||||
|
||||
import cacheBursting from '../../i18n-translations-hash.json';
|
||||
|
||||
@@ -24,7 +25,7 @@ i18n
|
||||
const ns = namespace[0];
|
||||
const pathkey = `/${language}/${ns}`;
|
||||
const hash = cacheBursting[pathkey as keyof typeof cacheBursting] || '';
|
||||
return `/locales/${language}/${namespace}.json?h=${hash}`;
|
||||
return `${getBasePath()}locales/${language}/${namespace}.json?h=${hash}`;
|
||||
},
|
||||
},
|
||||
react: {
|
||||
|
||||
106
frontend/src/api/generated/services/inframonitoring/index.ts
Normal file
106
frontend/src/api/generated/services/inframonitoring/index.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* ! Do not edit manually
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'yarn generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
import { useMutation } from 'react-query';
|
||||
import type {
|
||||
MutationFunction,
|
||||
UseMutationOptions,
|
||||
UseMutationResult,
|
||||
} from 'react-query';
|
||||
|
||||
import type {
|
||||
InframonitoringtypesPostableHostsDTO,
|
||||
ListHosts200,
|
||||
RenderErrorResponseDTO,
|
||||
} from '../sigNoz.schemas';
|
||||
|
||||
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
|
||||
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
|
||||
|
||||
/**
|
||||
* Returns a paginated list of hosts with key infrastructure metrics: CPU usage (%), memory usage (%), I/O wait (%), disk usage (%), and 15-minute load average. Each host includes its current status (active/inactive based on metrics reported in the last 10 minutes) and metadata attributes (e.g., os.type). Supports filtering via a filter expression, filtering by host status, custom groupBy to aggregate hosts by any attribute, ordering by any of the five metrics, and pagination via offset/limit. The response type is 'list' for the default host.name grouping or 'grouped_list' for custom groupBy keys. Also reports missing required metrics and whether the requested time range falls before the data retention boundary.
|
||||
* @summary List Hosts for Infra Monitoring
|
||||
*/
|
||||
export const listHosts = (
|
||||
inframonitoringtypesPostableHostsDTO: BodyType<InframonitoringtypesPostableHostsDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<ListHosts200>({
|
||||
url: `/api/v2/infra_monitoring/hosts`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: inframonitoringtypesPostableHostsDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getListHostsMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof listHosts>>,
|
||||
TError,
|
||||
{ data: BodyType<InframonitoringtypesPostableHostsDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof listHosts>>,
|
||||
TError,
|
||||
{ data: BodyType<InframonitoringtypesPostableHostsDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['listHosts'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof listHosts>>,
|
||||
{ data: BodyType<InframonitoringtypesPostableHostsDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return listHosts(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type ListHostsMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof listHosts>>
|
||||
>;
|
||||
export type ListHostsMutationBody =
|
||||
BodyType<InframonitoringtypesPostableHostsDTO>;
|
||||
export type ListHostsMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary List Hosts for Infra Monitoring
|
||||
*/
|
||||
export const useListHosts = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof listHosts>>,
|
||||
TError,
|
||||
{ data: BodyType<InframonitoringtypesPostableHostsDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof listHosts>>,
|
||||
TError,
|
||||
{ data: BodyType<InframonitoringtypesPostableHostsDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationOptions = getListHostsMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
@@ -471,7 +471,7 @@ export const getObjects = (
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetObjects200>({
|
||||
url: `/api/v1/roles/${id}/relation/${relation}/objects`,
|
||||
url: `/api/v1/roles/${id}/relations/${relation}/objects`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
@@ -481,7 +481,7 @@ export const getGetObjectsQueryKey = ({
|
||||
id,
|
||||
relation,
|
||||
}: GetObjectsPathParameters) => {
|
||||
return [`/api/v1/roles/${id}/relation/${relation}/objects`] as const;
|
||||
return [`/api/v1/roles/${id}/relations/${relation}/objects`] as const;
|
||||
};
|
||||
|
||||
export const getGetObjectsQueryOptions = <
|
||||
@@ -574,7 +574,7 @@ export const patchObjects = (
|
||||
authtypesPatchableObjectsDTO: BodyType<AuthtypesPatchableObjectsDTO>,
|
||||
) => {
|
||||
return GeneratedAPIInstance<string>({
|
||||
url: `/api/v1/roles/${id}/relation/${relation}/objects`,
|
||||
url: `/api/v1/roles/${id}/relations/${relation}/objects`,
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: authtypesPatchableObjectsDTO,
|
||||
|
||||
@@ -795,7 +795,8 @@ export interface CloudintegrationtypesAccountDTO {
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesAccountConfigDTO {
|
||||
aws: CloudintegrationtypesAWSAccountConfigDTO;
|
||||
aws?: CloudintegrationtypesAWSAccountConfigDTO;
|
||||
azure?: CloudintegrationtypesAzureAccountConfigDTO;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -829,6 +830,86 @@ export interface CloudintegrationtypesAssetsDTO {
|
||||
dashboards?: CloudintegrationtypesDashboardDTO[] | null;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesAzureAccountConfigDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
deploymentRegion: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
resourceGroups: string[];
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesAzureConnectionArtifactDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
cliCommand: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
cloudPowerShellCommand: string;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesAzureIntegrationConfigDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
deploymentRegion: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
resourceGroups: string[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
telemetryCollectionStrategy: CloudintegrationtypesAzureTelemetryCollectionStrategyDTO[];
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesAzureLogsCollectionStrategyDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
categoryGroups: string[];
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesAzureMetricsCollectionStrategyDTO {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesAzureServiceConfigDTO {
|
||||
logs: CloudintegrationtypesAzureServiceLogsConfigDTO;
|
||||
metrics: CloudintegrationtypesAzureServiceMetricsConfigDTO;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesAzureServiceLogsConfigDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesAzureServiceMetricsConfigDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesAzureTelemetryCollectionStrategyDTO {
|
||||
logs?: CloudintegrationtypesAzureLogsCollectionStrategyDTO;
|
||||
metrics?: CloudintegrationtypesAzureMetricsCollectionStrategyDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
resourceProvider: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
resourceType: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
@@ -890,7 +971,8 @@ export interface CloudintegrationtypesCollectedMetricDTO {
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesConnectionArtifactDTO {
|
||||
aws: CloudintegrationtypesAWSConnectionArtifactDTO;
|
||||
aws?: CloudintegrationtypesAWSConnectionArtifactDTO;
|
||||
azure?: CloudintegrationtypesAzureConnectionArtifactDTO;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesCredentialsDTO {
|
||||
@@ -1074,7 +1156,8 @@ export interface CloudintegrationtypesPostableAccountDTO {
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesPostableAccountConfigDTO {
|
||||
aws: CloudintegrationtypesAWSPostableAccountConfigDTO;
|
||||
aws?: CloudintegrationtypesAWSPostableAccountConfigDTO;
|
||||
azure?: CloudintegrationtypesAzureAccountConfigDTO;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1109,7 +1192,8 @@ export interface CloudintegrationtypesPostableAgentCheckInDTO {
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesProviderIntegrationConfigDTO {
|
||||
aws: CloudintegrationtypesAWSIntegrationConfigDTO;
|
||||
aws?: CloudintegrationtypesAWSIntegrationConfigDTO;
|
||||
azure?: CloudintegrationtypesAzureIntegrationConfigDTO;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesServiceDTO {
|
||||
@@ -1137,7 +1221,8 @@ export interface CloudintegrationtypesServiceDTO {
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesServiceConfigDTO {
|
||||
aws: CloudintegrationtypesAWSServiceConfigDTO;
|
||||
aws?: CloudintegrationtypesAWSServiceConfigDTO;
|
||||
azure?: CloudintegrationtypesAzureServiceConfigDTO;
|
||||
}
|
||||
|
||||
export enum CloudintegrationtypesServiceIDDTO {
|
||||
@@ -1154,6 +1239,8 @@ export enum CloudintegrationtypesServiceIDDTO {
|
||||
s3sync = 's3sync',
|
||||
sns = 'sns',
|
||||
sqs = 'sqs',
|
||||
storageaccountsblob = 'storageaccountsblob',
|
||||
cdnprofile = 'cdnprofile',
|
||||
}
|
||||
export interface CloudintegrationtypesServiceMetadataDTO {
|
||||
/**
|
||||
@@ -1186,11 +1273,24 @@ export interface CloudintegrationtypesSupportedSignalsDTO {
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesTelemetryCollectionStrategyDTO {
|
||||
aws: CloudintegrationtypesAWSTelemetryCollectionStrategyDTO;
|
||||
aws?: CloudintegrationtypesAWSTelemetryCollectionStrategyDTO;
|
||||
azure?: CloudintegrationtypesAzureTelemetryCollectionStrategyDTO;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesUpdatableAccountDTO {
|
||||
config: CloudintegrationtypesAccountConfigDTO;
|
||||
config: CloudintegrationtypesUpdatableAccountConfigDTO;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesUpdatableAccountConfigDTO {
|
||||
aws?: CloudintegrationtypesAWSAccountConfigDTO;
|
||||
azure?: CloudintegrationtypesUpdatableAzureAccountConfigDTO;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesUpdatableAzureAccountConfigDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
resourceGroups: string[];
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesUpdatableServiceDTO {
|
||||
@@ -3053,6 +3153,131 @@ export interface GlobaltypesTokenizerConfigDTO {
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesHostFilterDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
expression?: string;
|
||||
filterByStatus?: InframonitoringtypesHostStatusDTO;
|
||||
}
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type InframonitoringtypesHostRecordDTOMeta = {
|
||||
[key: string]: unknown;
|
||||
} | null;
|
||||
|
||||
export interface InframonitoringtypesHostRecordDTO {
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
activeHostCount: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
cpu: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
diskUsage: number;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
hostName: string;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
inactiveHostCount: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
load15: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
memory: number;
|
||||
/**
|
||||
* @type object
|
||||
* @nullable true
|
||||
*/
|
||||
meta: InframonitoringtypesHostRecordDTOMeta;
|
||||
status: InframonitoringtypesHostStatusDTO;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
wait: number;
|
||||
}
|
||||
|
||||
export enum InframonitoringtypesHostStatusDTO {
|
||||
active = 'active',
|
||||
inactive = 'inactive',
|
||||
'' = '',
|
||||
}
|
||||
export interface InframonitoringtypesHostsDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
endTimeBeforeRetention: boolean;
|
||||
/**
|
||||
* @type array
|
||||
* @nullable true
|
||||
*/
|
||||
records: InframonitoringtypesHostRecordDTO[] | null;
|
||||
requiredMetricsCheck: InframonitoringtypesRequiredMetricsCheckDTO;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
total: number;
|
||||
type: InframonitoringtypesResponseTypeDTO;
|
||||
warning?: Querybuildertypesv5QueryWarnDataDTO;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesPostableHostsDTO {
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
end: number;
|
||||
filter?: InframonitoringtypesHostFilterDTO;
|
||||
/**
|
||||
* @type array
|
||||
* @nullable true
|
||||
*/
|
||||
groupBy?: Querybuildertypesv5GroupByKeyDTO[] | null;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
limit: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
offset?: number;
|
||||
orderBy?: Querybuildertypesv5OrderByDTO;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
start: number;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesRequiredMetricsCheckDTO {
|
||||
/**
|
||||
* @type array
|
||||
* @nullable true
|
||||
*/
|
||||
missingMetrics: string[] | null;
|
||||
}
|
||||
|
||||
export enum InframonitoringtypesResponseTypeDTO {
|
||||
list = 'list',
|
||||
grouped_list = 'grouped_list',
|
||||
}
|
||||
export interface MetricsexplorertypesInspectMetricsRequestDTO {
|
||||
/**
|
||||
* @type integer
|
||||
@@ -5334,6 +5559,237 @@ export interface TelemetrytypesTelemetryFieldValuesDTO {
|
||||
|
||||
export type TimeDurationDTO = number;
|
||||
|
||||
export type TracedetailtypesEventDTOAttributeMap = { [key: string]: unknown };
|
||||
|
||||
export interface TracedetailtypesEventDTO {
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
attributeMap?: TracedetailtypesEventDTOAttributeMap;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
isError?: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
timeUnixNano?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type TracedetailtypesGettableWaterfallTraceDTOServiceNameToTotalDurationMap =
|
||||
{ [key: string]: number } | null;
|
||||
|
||||
export interface TracedetailtypesGettableWaterfallTraceDTO {
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
endTimestampMillis?: number;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
hasMissingSpans?: boolean;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
hasMore?: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
rootServiceEntryPoint?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
rootServiceName?: string;
|
||||
/**
|
||||
* @type object
|
||||
* @nullable true
|
||||
*/
|
||||
serviceNameToTotalDurationMap?: TracedetailtypesGettableWaterfallTraceDTOServiceNameToTotalDurationMap;
|
||||
/**
|
||||
* @type array
|
||||
* @nullable true
|
||||
*/
|
||||
spans?: TracedetailtypesWaterfallSpanDTO[] | null;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
startTimestampMillis?: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
totalErrorSpansCount?: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
totalSpansCount?: number;
|
||||
/**
|
||||
* @type array
|
||||
* @nullable true
|
||||
*/
|
||||
uncollapsedSpans?: string[] | null;
|
||||
}
|
||||
|
||||
export interface TracedetailtypesPostableWaterfallDTO {
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
selectedSpanId?: string;
|
||||
/**
|
||||
* @type array
|
||||
* @nullable true
|
||||
*/
|
||||
uncollapsedSpans?: string[] | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type TracedetailtypesWaterfallSpanDTOAttributes = {
|
||||
[key: string]: unknown;
|
||||
} | null;
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type TracedetailtypesWaterfallSpanDTOResource = {
|
||||
[key: string]: string;
|
||||
} | null;
|
||||
|
||||
export interface TracedetailtypesWaterfallSpanDTO {
|
||||
/**
|
||||
* @type object
|
||||
* @nullable true
|
||||
*/
|
||||
attributes?: TracedetailtypesWaterfallSpanDTOAttributes;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
db_name?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
db_operation?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
duration_nano?: number;
|
||||
/**
|
||||
* @type array
|
||||
* @nullable true
|
||||
*/
|
||||
events?: TracedetailtypesEventDTO[] | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
external_http_method?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
external_http_url?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
flags?: number;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
has_children?: boolean;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
has_error?: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
http_host?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
http_method?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
http_url?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
is_remote?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
kind_string?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
level?: number;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
parent_span_id?: string;
|
||||
/**
|
||||
* @type object
|
||||
* @nullable true
|
||||
*/
|
||||
resource?: TracedetailtypesWaterfallSpanDTOResource;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
response_status_code?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
span_id?: string;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
status_code?: number;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status_code_string?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status_message?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
sub_tree_node_count?: number;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
trace_id?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
trace_state?: string;
|
||||
}
|
||||
|
||||
export interface TypesAlertStatusDTO {
|
||||
/**
|
||||
* @type array
|
||||
@@ -6638,6 +7094,14 @@ export type Healthz503 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListHosts200 = {
|
||||
data: InframonitoringtypesHostsDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type Livez200 = {
|
||||
data: FactoryResponseDTO;
|
||||
/**
|
||||
@@ -7260,6 +7724,17 @@ export type GetHosts200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetWaterfallPathParameters = {
|
||||
traceID: string;
|
||||
};
|
||||
export type GetWaterfall200 = {
|
||||
data: TracedetailtypesGettableWaterfallTraceDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type QueryRangeV5200 = {
|
||||
data: Querybuildertypesv5QueryRangeResponseDTO;
|
||||
/**
|
||||
|
||||
123
frontend/src/api/generated/services/tracedetail/index.ts
Normal file
123
frontend/src/api/generated/services/tracedetail/index.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* ! Do not edit manually
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'yarn generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
import { useMutation } from 'react-query';
|
||||
import type {
|
||||
MutationFunction,
|
||||
UseMutationOptions,
|
||||
UseMutationResult,
|
||||
} from 'react-query';
|
||||
|
||||
import type {
|
||||
GetWaterfall200,
|
||||
GetWaterfallPathParameters,
|
||||
RenderErrorResponseDTO,
|
||||
TracedetailtypesPostableWaterfallDTO,
|
||||
} from '../sigNoz.schemas';
|
||||
|
||||
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
|
||||
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
|
||||
|
||||
/**
|
||||
* Returns the waterfall view of spans for a given trace ID with tree structure, metadata, and windowed pagination
|
||||
* @summary Get waterfall view for a trace
|
||||
*/
|
||||
export const getWaterfall = (
|
||||
{ traceID }: GetWaterfallPathParameters,
|
||||
tracedetailtypesPostableWaterfallDTO: BodyType<TracedetailtypesPostableWaterfallDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetWaterfall200>({
|
||||
url: `/api/v3/traces/${traceID}/waterfall`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: tracedetailtypesPostableWaterfallDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetWaterfallMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof getWaterfall>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: GetWaterfallPathParameters;
|
||||
data: BodyType<TracedetailtypesPostableWaterfallDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof getWaterfall>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: GetWaterfallPathParameters;
|
||||
data: BodyType<TracedetailtypesPostableWaterfallDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['getWaterfall'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof getWaterfall>>,
|
||||
{
|
||||
pathParams: GetWaterfallPathParameters;
|
||||
data: BodyType<TracedetailtypesPostableWaterfallDTO>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
|
||||
return getWaterfall(pathParams, data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type GetWaterfallMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getWaterfall>>
|
||||
>;
|
||||
export type GetWaterfallMutationBody =
|
||||
BodyType<TracedetailtypesPostableWaterfallDTO>;
|
||||
export type GetWaterfallMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get waterfall view for a trace
|
||||
*/
|
||||
export const useGetWaterfall = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof getWaterfall>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: GetWaterfallPathParameters;
|
||||
data: BodyType<TracedetailtypesPostableWaterfallDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof getWaterfall>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: GetWaterfallPathParameters;
|
||||
data: BodyType<TracedetailtypesPostableWaterfallDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationOptions = getGetWaterfallMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
interceptorRejected,
|
||||
interceptorsRequestBasePath,
|
||||
interceptorsRequestResponse,
|
||||
interceptorsResponse,
|
||||
} from 'api';
|
||||
@@ -17,6 +18,7 @@ export const GeneratedAPIInstance = <T>(
|
||||
return generatedAPIAxiosInstance({ ...config }).then(({ data }) => data);
|
||||
};
|
||||
|
||||
generatedAPIAxiosInstance.interceptors.request.use(interceptorsRequestBasePath);
|
||||
generatedAPIAxiosInstance.interceptors.request.use(interceptorsRequestResponse);
|
||||
generatedAPIAxiosInstance.interceptors.response.use(
|
||||
interceptorsResponse,
|
||||
|
||||
@@ -11,6 +11,7 @@ import axios, {
|
||||
import { ENVIRONMENT } from 'constants/env';
|
||||
import { Events } from 'constants/events';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { getBasePath } from 'utils/basePath';
|
||||
import { eventEmitter } from 'utils/getEventEmitter';
|
||||
|
||||
import apiV1, { apiAlertManager, apiV2, apiV3, apiV4, apiV5 } from './apiV1';
|
||||
@@ -67,6 +68,39 @@ export const interceptorsRequestResponse = (
|
||||
return value;
|
||||
};
|
||||
|
||||
// Strips the leading '/' from path and joins with base — idempotent if already prefixed.
|
||||
// e.g. prependBase('/signoz/', '/api/v1/') → '/signoz/api/v1/'
|
||||
function prependBase(base: string, path: string): string {
|
||||
return path.startsWith(base) ? path : base + path.slice(1);
|
||||
}
|
||||
|
||||
// Prepends the runtime base path to outgoing requests so API calls work under
|
||||
// a URL prefix (e.g. /signoz/api/v1/…). No-op for root deployments and dev
|
||||
// (dev baseURL is a full http:// URL, not an absolute path).
|
||||
export const interceptorsRequestBasePath = (
|
||||
value: InternalAxiosRequestConfig,
|
||||
): InternalAxiosRequestConfig => {
|
||||
const basePath = getBasePath();
|
||||
if (basePath === '/') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (value.baseURL?.startsWith('/')) {
|
||||
// Production relative baseURL: '/api/v1/' → '/signoz/api/v1/'
|
||||
value.baseURL = prependBase(basePath, value.baseURL);
|
||||
} else if (value.baseURL?.startsWith('http')) {
|
||||
// Dev absolute baseURL (VITE_FRONTEND_API_ENDPOINT): 'https://host/api/v1/' → 'https://host/signoz/api/v1/'
|
||||
const url = new URL(value.baseURL);
|
||||
url.pathname = prependBase(basePath, url.pathname);
|
||||
value.baseURL = url.toString();
|
||||
} else if (!value.baseURL && value.url?.startsWith('/')) {
|
||||
// Orval-generated client (empty baseURL, path in url): '/api/signoz/v1/rules' → '/signoz/api/signoz/v1/rules'
|
||||
value.url = prependBase(basePath, value.url);
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
export const interceptorRejected = async (
|
||||
value: AxiosResponse<any>,
|
||||
): Promise<AxiosResponse<any>> => {
|
||||
@@ -133,6 +167,7 @@ const instance = axios.create({
|
||||
});
|
||||
|
||||
instance.interceptors.request.use(interceptorsRequestResponse);
|
||||
instance.interceptors.request.use(interceptorsRequestBasePath);
|
||||
instance.interceptors.response.use(interceptorsResponse, interceptorRejected);
|
||||
|
||||
export const AxiosAlertManagerInstance = axios.create({
|
||||
@@ -147,6 +182,7 @@ ApiV2Instance.interceptors.response.use(
|
||||
interceptorRejected,
|
||||
);
|
||||
ApiV2Instance.interceptors.request.use(interceptorsRequestResponse);
|
||||
ApiV2Instance.interceptors.request.use(interceptorsRequestBasePath);
|
||||
|
||||
// axios V3
|
||||
export const ApiV3Instance = axios.create({
|
||||
@@ -158,6 +194,7 @@ ApiV3Instance.interceptors.response.use(
|
||||
interceptorRejected,
|
||||
);
|
||||
ApiV3Instance.interceptors.request.use(interceptorsRequestResponse);
|
||||
ApiV3Instance.interceptors.request.use(interceptorsRequestBasePath);
|
||||
//
|
||||
|
||||
// axios V4
|
||||
@@ -170,6 +207,7 @@ ApiV4Instance.interceptors.response.use(
|
||||
interceptorRejected,
|
||||
);
|
||||
ApiV4Instance.interceptors.request.use(interceptorsRequestResponse);
|
||||
ApiV4Instance.interceptors.request.use(interceptorsRequestBasePath);
|
||||
//
|
||||
|
||||
// axios V5
|
||||
@@ -182,6 +220,7 @@ ApiV5Instance.interceptors.response.use(
|
||||
interceptorRejected,
|
||||
);
|
||||
ApiV5Instance.interceptors.request.use(interceptorsRequestResponse);
|
||||
ApiV5Instance.interceptors.request.use(interceptorsRequestBasePath);
|
||||
//
|
||||
|
||||
// axios Base
|
||||
@@ -194,6 +233,7 @@ LogEventAxiosInstance.interceptors.response.use(
|
||||
interceptorRejectedBase,
|
||||
);
|
||||
LogEventAxiosInstance.interceptors.request.use(interceptorsRequestResponse);
|
||||
LogEventAxiosInstance.interceptors.request.use(interceptorsRequestBasePath);
|
||||
//
|
||||
|
||||
AxiosAlertManagerInstance.interceptors.response.use(
|
||||
@@ -201,6 +241,7 @@ AxiosAlertManagerInstance.interceptors.response.use(
|
||||
interceptorRejected,
|
||||
);
|
||||
AxiosAlertManagerInstance.interceptors.request.use(interceptorsRequestResponse);
|
||||
AxiosAlertManagerInstance.interceptors.request.use(interceptorsRequestBasePath);
|
||||
|
||||
export { apiV1 };
|
||||
export default instance;
|
||||
|
||||
@@ -12,6 +12,7 @@ import { AppState } from 'store/reducers';
|
||||
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource, MetricAggregateOperator } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { withBasePath } from 'utils/basePath';
|
||||
|
||||
export interface NavigateToExplorerProps {
|
||||
filters: TagFilterItem[];
|
||||
@@ -133,7 +134,7 @@ export function useNavigateToExplorer(): (
|
||||
QueryParams.compositeQuery
|
||||
}=${JSONCompositeQuery}`;
|
||||
|
||||
window.open(newExplorerPath, sameTab ? '_self' : '_blank');
|
||||
window.open(withBasePath(newExplorerPath), sameTab ? '_self' : '_blank');
|
||||
},
|
||||
[
|
||||
prepareQuery,
|
||||
|
||||
@@ -164,7 +164,8 @@ export function useIntegrationModal({
|
||||
{
|
||||
onSuccess: (response: CreateAccountMutationResult) => {
|
||||
const accountId = response.data.id;
|
||||
const connectionUrl = response.data.connectionArtifact.aws.connectionUrl;
|
||||
const connectionUrl =
|
||||
response.data.connectionArtifact.aws?.connectionUrl ?? '';
|
||||
|
||||
logEvent(
|
||||
'AWS Integration: Account connection attempt redirected to AWS',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom-v5-compat';
|
||||
import { cloneDeep, isEqual } from 'lodash-es';
|
||||
import { withBasePath } from 'utils/basePath';
|
||||
|
||||
interface NavigateOptions {
|
||||
replace?: boolean;
|
||||
@@ -130,7 +131,7 @@ export const useSafeNavigate = (
|
||||
typeof to === 'string'
|
||||
? to
|
||||
: `${to.pathname || location.pathname}${to.search || ''}`;
|
||||
window.open(targetPath, '_blank');
|
||||
window.open(withBasePath(targetPath), '_blank');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createBrowserHistory } from 'history';
|
||||
import { getBasePath } from 'utils/basePath';
|
||||
|
||||
export default createBrowserHistory();
|
||||
export default createBrowserHistory({ basename: getBasePath() });
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
.headerContainer {
|
||||
padding: 1rem 1rem 0 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
|
||||
&:last-child {
|
||||
padding-bottom: var(--spacing-4);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.headerRow {
|
||||
padding: var(--spacing-8) var(--spacing-8) 0 var(--spacing-8);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-2);
|
||||
margin-bottom: var(--spacing-2);
|
||||
}
|
||||
|
||||
.pinnedItem {
|
||||
padding: var(--spacing-4) var(--spacing-4) 0 var(--spacing-4);
|
||||
}
|
||||
|
||||
.status {
|
||||
|
||||
@@ -72,13 +72,15 @@ export default function TooltipHeader({
|
||||
)}
|
||||
|
||||
{activeItem && (
|
||||
<TooltipItem
|
||||
item={activeItem}
|
||||
isItemActive={true}
|
||||
containerTestId="uplot-tooltip-pinned"
|
||||
markerTestId="uplot-tooltip-pinned-marker"
|
||||
contentTestId="uplot-tooltip-pinned-content"
|
||||
/>
|
||||
<div className={Styles.pinnedItem} data-testid="uplot-tooltip-pinned-item">
|
||||
<TooltipItem
|
||||
item={activeItem}
|
||||
isItemActive={true}
|
||||
containerTestId="uplot-tooltip-pinned"
|
||||
markerTestId="uplot-tooltip-pinned-marker"
|
||||
contentTestId="uplot-tooltip-pinned-content"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,36 +1,51 @@
|
||||
.uplot-tooltip-item {
|
||||
.uplotTooltipItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 0;
|
||||
gap: var(--spacing-2);
|
||||
padding: 6px;
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
font-weight: 400;
|
||||
|
||||
.uplot-tooltip-item-marker {
|
||||
border-radius: 50%;
|
||||
border-style: solid;
|
||||
border-width: 2px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
flex-shrink: 0;
|
||||
&[data-is-active='true'] {
|
||||
opacity: 1;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.uplot-tooltip-item-content {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
justify-content: space-between;
|
||||
|
||||
.uplot-tooltip-item-label {
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
&-separator {
|
||||
flex: 1;
|
||||
border-width: 0.5px;
|
||||
border-style: dashed;
|
||||
min-width: 24px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
&:hover {
|
||||
opacity: 1 !important;
|
||||
background-color: var(--l2-border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.uplotTooltipItemMarker {
|
||||
border-radius: 50%;
|
||||
border-style: solid;
|
||||
border-width: 2px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
box-sizing: border-box;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.uplotTooltipItemContent {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.uplotTooltipItemLabel {
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.uplotTooltipItemContentSeparator {
|
||||
flex: 1;
|
||||
border-width: 0.5px;
|
||||
border-style: dashed;
|
||||
min-width: 24px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
@@ -20,10 +20,7 @@ export default function TooltipItem({
|
||||
return (
|
||||
<div
|
||||
className={Styles.uplotTooltipItem}
|
||||
style={{
|
||||
opacity: isItemActive ? 1 : 0.7,
|
||||
fontWeight: isItemActive ? 700 : 400,
|
||||
}}
|
||||
data-is-active={isItemActive}
|
||||
data-testid={containerTestId}
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
:global(div[data-viewport-type='element']) {
|
||||
left: 0;
|
||||
box-sizing: border-box;
|
||||
padding: 4px 12px 4px 16px;
|
||||
padding: 0px var(--spacing-2) 0 var(--spacing-4);
|
||||
|
||||
[data-test-id='virtuoso-item-list'] > * + * {
|
||||
margin-top: var(--spacing-2);
|
||||
}
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
|
||||
@@ -4,6 +4,7 @@ import ROUTES from 'constants/routes';
|
||||
import { handleContactSupport } from 'container/Integrations/utils';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { Home, LifeBuoy } from 'lucide-react';
|
||||
import { withBasePath } from 'utils/basePath';
|
||||
|
||||
import cloudUrl from '@/assets/Images/cloud.svg';
|
||||
|
||||
@@ -11,8 +12,8 @@ import './ErrorBoundaryFallback.styles.scss';
|
||||
|
||||
function ErrorBoundaryFallback(): JSX.Element {
|
||||
const handleReload = (): void => {
|
||||
// Go to home page
|
||||
window.location.href = ROUTES.HOME;
|
||||
// Hard reload resets Sentry.ErrorBoundary state; withBasePath preserves any /signoz/ prefix.
|
||||
window.location.href = withBasePath(ROUTES.HOME);
|
||||
};
|
||||
|
||||
const { isCloudUser: isCloudUserVal } = useGetTenantLicense();
|
||||
|
||||
118
frontend/src/utils/__tests__/base-path.test.ts
Normal file
118
frontend/src/utils/__tests__/base-path.test.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* basePath is memoized at module init, so each describe block isolates the
|
||||
* module with a fresh DOM state using jest.isolateModules + require.
|
||||
*/
|
||||
|
||||
type BasePath = typeof import('../basePath');
|
||||
|
||||
function loadModule(href?: string): BasePath {
|
||||
if (href !== undefined) {
|
||||
const base = document.createElement('base');
|
||||
base.setAttribute('href', href);
|
||||
document.head.append(base);
|
||||
}
|
||||
|
||||
let mod!: BasePath;
|
||||
jest.isolateModules(() => {
|
||||
// oxlint-disable-next-line typescript-eslint/no-require-imports, typescript-eslint/no-var-requires
|
||||
mod = require('../basePath');
|
||||
});
|
||||
return mod;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const el of document.head.querySelectorAll('base')) { el.remove(); }
|
||||
});
|
||||
|
||||
describe('at basePath="/"', () => {
|
||||
let m: BasePath;
|
||||
beforeEach(() => {
|
||||
m = loadModule('/');
|
||||
});
|
||||
|
||||
it('getBasePath returns "/"', () => {
|
||||
expect(m.getBasePath()).toBe('/');
|
||||
});
|
||||
|
||||
it('withBasePath is a no-op for any internal path', () => {
|
||||
expect(m.withBasePath('/logs')).toBe('/logs');
|
||||
expect(m.withBasePath('/logs/explorer')).toBe('/logs/explorer');
|
||||
});
|
||||
|
||||
it('withBasePath passes through external URLs', () => {
|
||||
expect(m.withBasePath('https://example.com/foo')).toBe(
|
||||
'https://example.com/foo',
|
||||
);
|
||||
});
|
||||
|
||||
it('getAbsoluteUrl returns origin + path', () => {
|
||||
expect(m.getAbsoluteUrl('/logs')).toBe(`${window.location.origin}/logs`);
|
||||
});
|
||||
|
||||
it('getBaseUrl returns bare origin', () => {
|
||||
expect(m.getBaseUrl()).toBe(window.location.origin);
|
||||
});
|
||||
});
|
||||
|
||||
describe('at basePath="/signoz/"', () => {
|
||||
let m: BasePath;
|
||||
beforeEach(() => {
|
||||
m = loadModule('/signoz/');
|
||||
});
|
||||
|
||||
it('getBasePath returns "/signoz/"', () => {
|
||||
expect(m.getBasePath()).toBe('/signoz/');
|
||||
});
|
||||
|
||||
it('withBasePath prepends the prefix', () => {
|
||||
expect(m.withBasePath('/logs')).toBe('/signoz/logs');
|
||||
expect(m.withBasePath('/logs/explorer')).toBe('/signoz/logs/explorer');
|
||||
});
|
||||
|
||||
it('withBasePath is idempotent — safe to call twice', () => {
|
||||
expect(m.withBasePath('/signoz/logs')).toBe('/signoz/logs');
|
||||
});
|
||||
|
||||
it('withBasePath is idempotent when path equals the prefix without trailing slash', () => {
|
||||
expect(m.withBasePath('/signoz')).toBe('/signoz');
|
||||
});
|
||||
|
||||
it('withBasePath passes through external URLs', () => {
|
||||
expect(m.withBasePath('https://example.com/foo')).toBe(
|
||||
'https://example.com/foo',
|
||||
);
|
||||
});
|
||||
|
||||
it('getAbsoluteUrl returns origin + prefixed path', () => {
|
||||
expect(m.getAbsoluteUrl('/logs')).toBe(
|
||||
`${window.location.origin}/signoz/logs`,
|
||||
);
|
||||
});
|
||||
|
||||
it('getBaseUrl returns origin + prefix without trailing slash', () => {
|
||||
expect(m.getBaseUrl()).toBe(`${window.location.origin}/signoz`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('no <base> tag', () => {
|
||||
it('getBasePath falls back to "/"', () => {
|
||||
const m = loadModule();
|
||||
expect(m.getBasePath()).toBe('/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('href without trailing slash', () => {
|
||||
it('normalises to trailing slash', () => {
|
||||
const m = loadModule('/signoz');
|
||||
expect(m.getBasePath()).toBe('/signoz/');
|
||||
expect(m.withBasePath('/logs')).toBe('/signoz/logs');
|
||||
});
|
||||
});
|
||||
|
||||
describe('nested prefix "/a/b/prefix/"', () => {
|
||||
it('withBasePath handles arbitrary depth', () => {
|
||||
const m = loadModule('/a/b/prefix/');
|
||||
expect(m.withBasePath('/logs')).toBe('/a/b/prefix/logs');
|
||||
expect(m.withBasePath('/a/b/prefix/logs')).toBe('/a/b/prefix/logs');
|
||||
});
|
||||
});
|
||||
@@ -1,25 +1,38 @@
|
||||
import { isModifierKeyPressed } from '../app';
|
||||
import { openInNewTab } from '../navigation';
|
||||
|
||||
type NavigationModule = typeof import('../navigation');
|
||||
|
||||
function loadNavigationModule(href?: string): NavigationModule {
|
||||
if (href !== undefined) {
|
||||
const base = document.createElement('base');
|
||||
base.setAttribute('href', href);
|
||||
document.head.append(base);
|
||||
}
|
||||
let mod!: NavigationModule;
|
||||
jest.isolateModules(() => {
|
||||
// oxlint-disable-next-line typescript-eslint/no-require-imports, typescript-eslint/no-var-requires
|
||||
mod = require('../navigation');
|
||||
});
|
||||
return mod;
|
||||
}
|
||||
|
||||
const createMouseEvent = (overrides: Partial<MouseEvent> = {}): MouseEvent =>
|
||||
({
|
||||
metaKey: false,
|
||||
ctrlKey: false,
|
||||
button: 0,
|
||||
...overrides,
|
||||
}) as MouseEvent;
|
||||
|
||||
describe('navigation utilities', () => {
|
||||
const originalWindowOpen = window.open;
|
||||
|
||||
beforeEach(() => {
|
||||
window.open = jest.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.open = originalWindowOpen;
|
||||
for (const el of document.head.querySelectorAll('base')) { el.remove(); }
|
||||
});
|
||||
|
||||
describe('isModifierKeyPressed', () => {
|
||||
const createMouseEvent = (overrides: Partial<MouseEvent> = {}): MouseEvent =>
|
||||
({
|
||||
metaKey: false,
|
||||
ctrlKey: false,
|
||||
button: 0,
|
||||
...overrides,
|
||||
} as MouseEvent);
|
||||
|
||||
it('returns true when metaKey is pressed (Cmd on Mac)', () => {
|
||||
const event = createMouseEvent({ metaKey: true });
|
||||
@@ -56,25 +69,59 @@ describe('navigation utilities', () => {
|
||||
});
|
||||
|
||||
describe('openInNewTab', () => {
|
||||
it('calls window.open with the given path and _blank target', () => {
|
||||
openInNewTab('/dashboard');
|
||||
expect(window.open).toHaveBeenCalledWith('/dashboard', '_blank');
|
||||
describe('at basePath="/"', () => {
|
||||
let m: NavigationModule;
|
||||
beforeEach(() => {
|
||||
jest.spyOn(window, 'open').mockImplementation();
|
||||
m = loadNavigationModule('/');
|
||||
});
|
||||
|
||||
it('passes internal path through unchanged', () => {
|
||||
m.openInNewTab('/dashboard');
|
||||
expect(window.open).toHaveBeenCalledWith('/dashboard', '_blank');
|
||||
});
|
||||
|
||||
it('passes through external URLs unchanged', () => {
|
||||
m.openInNewTab('https://example.com/page');
|
||||
expect(window.open).toHaveBeenCalledWith(
|
||||
'https://example.com/page',
|
||||
'_blank',
|
||||
);
|
||||
});
|
||||
|
||||
it('handles paths with query strings', () => {
|
||||
m.openInNewTab('/alerts?tab=AlertRules&relativeTime=30m');
|
||||
expect(window.open).toHaveBeenCalledWith(
|
||||
'/alerts?tab=AlertRules&relativeTime=30m',
|
||||
'_blank',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('handles full URLs', () => {
|
||||
openInNewTab('https://example.com/page');
|
||||
expect(window.open).toHaveBeenCalledWith(
|
||||
'https://example.com/page',
|
||||
'_blank',
|
||||
);
|
||||
});
|
||||
describe('at basePath="/signoz/"', () => {
|
||||
let m: NavigationModule;
|
||||
beforeEach(() => {
|
||||
jest.spyOn(window, 'open').mockImplementation();
|
||||
m = loadNavigationModule('/signoz/');
|
||||
});
|
||||
|
||||
it('handles paths with query strings', () => {
|
||||
openInNewTab('/alerts?tab=AlertRules&relativeTime=30m');
|
||||
expect(window.open).toHaveBeenCalledWith(
|
||||
'/alerts?tab=AlertRules&relativeTime=30m',
|
||||
'_blank',
|
||||
);
|
||||
it('prepends base path to internal paths', () => {
|
||||
m.openInNewTab('/dashboard');
|
||||
expect(window.open).toHaveBeenCalledWith('/signoz/dashboard', '_blank');
|
||||
});
|
||||
|
||||
it('passes through external URLs unchanged', () => {
|
||||
m.openInNewTab('https://example.com/page');
|
||||
expect(window.open).toHaveBeenCalledWith(
|
||||
'https://example.com/page',
|
||||
'_blank',
|
||||
);
|
||||
});
|
||||
|
||||
it('is idempotent — does not double-prefix an already-prefixed path', () => {
|
||||
m.openInNewTab('/signoz/dashboard');
|
||||
expect(window.open).toHaveBeenCalledWith('/signoz/dashboard', '_blank');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
50
frontend/src/utils/basePath.ts
Normal file
50
frontend/src/utils/basePath.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
// Read once at module init — avoids a DOM query on every axios request.
|
||||
const _basePath: string = ((): string => {
|
||||
const href = document.querySelector('base')?.getAttribute('href') ?? '/';
|
||||
return href.endsWith('/') ? href : `${href}/`;
|
||||
})();
|
||||
|
||||
/** Returns the runtime base path — always trailing-slashed. e.g. "/" or "/signoz/" */
|
||||
export function getBasePath(): string {
|
||||
return _basePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepends the base path to an internal absolute path.
|
||||
* Idempotent and safe to call on any value.
|
||||
*
|
||||
* withBasePath('/logs') → '/signoz/logs'
|
||||
* withBasePath('/signoz/logs') → '/signoz/logs' (already prefixed)
|
||||
* withBasePath('https://x.com') → 'https://x.com' (external, passthrough)
|
||||
*/
|
||||
export function withBasePath(path: string): string {
|
||||
if (!path.startsWith('/')) {
|
||||
return path;
|
||||
}
|
||||
if (_basePath === '/') {
|
||||
return path;
|
||||
}
|
||||
if (path.startsWith(_basePath) || path === _basePath.slice(0, -1)) {
|
||||
return path;
|
||||
}
|
||||
return _basePath + path.slice(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Full absolute URL — for copy-to-clipboard and window.open calls.
|
||||
* getAbsoluteUrl(ROUTES.LOGS_EXPLORER) → 'https://host/signoz/logs/logs-explorer'
|
||||
*/
|
||||
export function getAbsoluteUrl(path: string): string {
|
||||
return window.location.origin + withBasePath(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Origin + base path without trailing slash — for sending to the backend
|
||||
* as frontendBaseUrl in invite / password-reset email flows.
|
||||
* getBaseUrl() → 'https://host/signoz'
|
||||
*/
|
||||
export function getBaseUrl(): string {
|
||||
return (
|
||||
window.location.origin + (_basePath === '/' ? '' : _basePath.slice(0, -1))
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
/**
|
||||
* Opens the given path in a new browser tab.
|
||||
*/
|
||||
import { withBasePath } from 'utils/basePath';
|
||||
|
||||
export const openInNewTab = (path: string): void => {
|
||||
window.open(path, '_blank');
|
||||
window.open(withBasePath(path), '_blank');
|
||||
};
|
||||
|
||||
@@ -34,7 +34,7 @@ const ruleName = 'local/no-unsupported-asset-url';
|
||||
* e.g. "url('/a.svg') url('/b.png') repeat" → ['/a.svg', '/b.png']
|
||||
*/
|
||||
function extractUrlPaths(value) {
|
||||
if (typeof value !== 'string') return [];
|
||||
if (typeof value !== 'string') {return [];}
|
||||
const paths = [];
|
||||
const urlPattern = /url\(\s*['"]?([^'")\s]+)['"]?\s*\)/g;
|
||||
let match = urlPattern.exec(value);
|
||||
@@ -71,7 +71,7 @@ const messages = stylelint.utils.ruleMessages(ruleName, {
|
||||
*/
|
||||
const rule = (primaryOption) => {
|
||||
return (root, result) => {
|
||||
if (!primaryOption) return;
|
||||
if (!primaryOption) {return;}
|
||||
|
||||
root.walkDecls((decl) => {
|
||||
const urlPaths = extractUrlPaths(decl.value);
|
||||
|
||||
@@ -66,8 +66,6 @@
|
||||
"./vite.config.ts",
|
||||
"./jest.setup.ts",
|
||||
"./tests/**.ts",
|
||||
"./**/*.d.ts",
|
||||
"./playwright.config.ts",
|
||||
"./e2e/**/*.ts"
|
||||
"./**/*.d.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -10,6 +10,19 @@ import { createHtmlPlugin } from 'vite-plugin-html';
|
||||
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer';
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
|
||||
// In dev the Go backend is not involved, so replace the [[.BaseHref]] placeholder
|
||||
// with the configured base path so relative assets resolve correctly from the Vite dev server.
|
||||
// Set VITE_BASE_PATH env var to test with a custom base path (e.g., /signoz/).
|
||||
function devBasePathPlugin(basePath: string): Plugin {
|
||||
return {
|
||||
name: 'dev-base-path',
|
||||
apply: 'serve',
|
||||
transformIndexHtml(html): string {
|
||||
return html.replaceAll('[[.BaseHref]]', basePath);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function rawMarkdownPlugin(): Plugin {
|
||||
return {
|
||||
name: 'raw-markdown',
|
||||
@@ -28,10 +41,13 @@ function rawMarkdownPlugin(): Plugin {
|
||||
export default defineConfig(
|
||||
({ mode }): UserConfig => {
|
||||
const env = loadEnv(mode, process.cwd(), '');
|
||||
// Base path for serving the app (e.g., '/signoz/'). Defaults to '/'.
|
||||
const basePath = env.VITE_BASE_PATH || '/';
|
||||
|
||||
const plugins = [
|
||||
tsconfigPaths(),
|
||||
rawMarkdownPlugin(),
|
||||
devBasePathPlugin(basePath),
|
||||
react(),
|
||||
createHtmlPlugin({
|
||||
inject: {
|
||||
@@ -124,6 +140,9 @@ export default defineConfig(
|
||||
'process.env.TUNNEL_DOMAIN': JSON.stringify(env.VITE_TUNNEL_DOMAIN),
|
||||
'process.env.DOCS_BASE_URL': JSON.stringify(env.VITE_DOCS_BASE_URL),
|
||||
},
|
||||
// In production, use relative paths so assets work with any base path injected by the backend.
|
||||
// In dev, use the configured base path for proper HMR and routing.
|
||||
base: mode === 'production' ? './' : basePath,
|
||||
build: {
|
||||
sourcemap: true,
|
||||
outDir: 'build',
|
||||
|
||||
@@ -4540,13 +4540,6 @@
|
||||
resolved "https://registry.yarnpkg.com/@pkgr/core/-/core-0.2.9.tgz#d229a7b7f9dac167a156992ef23c7f023653f53b"
|
||||
integrity sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==
|
||||
|
||||
"@playwright/test@1.55.1":
|
||||
version "1.55.1"
|
||||
resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.55.1.tgz#80f775d5f948cd3ef550fcc45ef99986d3ffb36c"
|
||||
integrity sha512-IVAh/nOJaw6W9g+RJVlIQJ6gSiER+ae6mKQ5CX1bERzQgbC1VSeBlwdvczT7pxb0GWiyrxH4TGKbMfDb4Sq/ig==
|
||||
dependencies:
|
||||
playwright "1.55.1"
|
||||
|
||||
"@posthog/core@1.6.0":
|
||||
version "1.6.0"
|
||||
resolved "https://registry.yarnpkg.com/@posthog/core/-/core-1.6.0.tgz#a5b63a30950a8dfe87d4bf335ab24005c7ce1278"
|
||||
@@ -10568,7 +10561,7 @@ fscreen@^1.0.2:
|
||||
resolved "https://registry.yarnpkg.com/fscreen/-/fscreen-1.2.0.tgz#1a8c88e06bc16a07b473ad96196fb06d6657f59e"
|
||||
integrity sha512-hlq4+BU0hlPmwsFjwGGzZ+OZ9N/wq9Ljg/sq3pX+2CD7hrJsX9tJgWWK/wiNTFM212CLHWhicOoqwXyZGGetJg==
|
||||
|
||||
fsevents@2.3.2, fsevents@^2.3.2, fsevents@~2.3.2:
|
||||
fsevents@^2.3.2, fsevents@~2.3.2:
|
||||
version "2.3.2"
|
||||
resolved "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz"
|
||||
integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==
|
||||
@@ -15468,20 +15461,6 @@ pkg-dir@^7.0.0:
|
||||
dependencies:
|
||||
find-up "^6.3.0"
|
||||
|
||||
playwright-core@1.55.1:
|
||||
version "1.55.1"
|
||||
resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.55.1.tgz#5d3bb1846bc4289d364ea1a9dcb33f14545802e9"
|
||||
integrity sha512-Z6Mh9mkwX+zxSlHqdr5AOcJnfp+xUWLCt9uKV18fhzA8eyxUd8NUWzAjxUh55RZKSYwDGX0cfaySdhZJGMoJ+w==
|
||||
|
||||
playwright@1.55.1:
|
||||
version "1.55.1"
|
||||
resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.55.1.tgz#8a9954e9e61ed1ab479212af9be336888f8b3f0e"
|
||||
integrity sha512-cJW4Xd/G3v5ovXtJJ52MAOclqeac9S/aGGgRzLabuF8TnIb6xHvMzKIa6JmrRzUkeXJgfL1MhukP0NK6l39h3A==
|
||||
dependencies:
|
||||
playwright-core "1.55.1"
|
||||
optionalDependencies:
|
||||
fsevents "2.3.2"
|
||||
|
||||
pony-cause@^1.1.1:
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/pony-cause/-/pony-cause-1.1.1.tgz#f795524f83bebbf1878bd3587b45f69143cbf3f9"
|
||||
|
||||
33
pkg/apiserver/signozapiserver/inframonitoring.go
Normal file
33
pkg/apiserver/signozapiserver/inframonitoring.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package signozapiserver
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/http/handler"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/inframonitoringtypes"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
func (provider *provider) addInfraMonitoringRoutes(router *mux.Router) error {
|
||||
if err := router.Handle("/api/v2/infra_monitoring/hosts", handler.New(
|
||||
provider.authZ.ViewAccess(provider.infraMonitoringHandler.ListHosts),
|
||||
handler.OpenAPIDef{
|
||||
ID: "ListHosts",
|
||||
Tags: []string{"inframonitoring"},
|
||||
Summary: "List Hosts for Infra Monitoring",
|
||||
Description: "Returns a paginated list of hosts with key infrastructure metrics: CPU usage (%), memory usage (%), I/O wait (%), disk usage (%), and 15-minute load average. Each host includes its current status (active/inactive based on metrics reported in the last 10 minutes) and metadata attributes (e.g., os.type). Supports filtering via a filter expression, filtering by host status, custom groupBy to aggregate hosts by any attribute, ordering by any of the five metrics, and pagination via offset/limit. The response type is 'list' for the default host.name grouping or 'grouped_list' for custom groupBy keys. Also reports missing required metrics and whether the requested time range falls before the data retention boundary.",
|
||||
Request: new(inframonitoringtypes.PostableHosts),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(inframonitoringtypes.Hosts),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/fields"
|
||||
"github.com/SigNoz/signoz/pkg/modules/metricsexplorer"
|
||||
"github.com/SigNoz/signoz/pkg/modules/inframonitoring"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/preference"
|
||||
"github.com/SigNoz/signoz/pkg/modules/promote"
|
||||
@@ -24,6 +25,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
|
||||
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
|
||||
"github.com/SigNoz/signoz/pkg/modules/session"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tracedetail"
|
||||
"github.com/SigNoz/signoz/pkg/modules/user"
|
||||
"github.com/SigNoz/signoz/pkg/querier"
|
||||
"github.com/SigNoz/signoz/pkg/ruler"
|
||||
@@ -49,6 +51,7 @@ type provider struct {
|
||||
dashboardModule dashboard.Module
|
||||
dashboardHandler dashboard.Handler
|
||||
metricsExplorerHandler metricsexplorer.Handler
|
||||
infraMonitoringHandler inframonitoring.Handler
|
||||
gatewayHandler gateway.Handler
|
||||
fieldsHandler fields.Handler
|
||||
authzHandler authz.Handler
|
||||
@@ -60,6 +63,7 @@ type provider struct {
|
||||
cloudIntegrationHandler cloudintegration.Handler
|
||||
ruleStateHistoryHandler rulestatehistory.Handler
|
||||
alertmanagerHandler alertmanager.Handler
|
||||
traceDetailHandler tracedetail.Handler
|
||||
rulerHandler ruler.Handler
|
||||
}
|
||||
|
||||
@@ -77,6 +81,7 @@ func NewFactory(
|
||||
dashboardModule dashboard.Module,
|
||||
dashboardHandler dashboard.Handler,
|
||||
metricsExplorerHandler metricsexplorer.Handler,
|
||||
infraMonitoringHandler inframonitoring.Handler,
|
||||
gatewayHandler gateway.Handler,
|
||||
fieldsHandler fields.Handler,
|
||||
authzHandler authz.Handler,
|
||||
@@ -88,6 +93,7 @@ func NewFactory(
|
||||
cloudIntegrationHandler cloudintegration.Handler,
|
||||
ruleStateHistoryHandler rulestatehistory.Handler,
|
||||
alertmanagerHandler alertmanager.Handler,
|
||||
traceDetailHandler tracedetail.Handler,
|
||||
rulerHandler ruler.Handler,
|
||||
) factory.ProviderFactory[apiserver.APIServer, apiserver.Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("signoz"), func(ctx context.Context, providerSettings factory.ProviderSettings, config apiserver.Config) (apiserver.APIServer, error) {
|
||||
@@ -108,6 +114,7 @@ func NewFactory(
|
||||
dashboardModule,
|
||||
dashboardHandler,
|
||||
metricsExplorerHandler,
|
||||
infraMonitoringHandler,
|
||||
gatewayHandler,
|
||||
fieldsHandler,
|
||||
authzHandler,
|
||||
@@ -119,6 +126,7 @@ func NewFactory(
|
||||
cloudIntegrationHandler,
|
||||
ruleStateHistoryHandler,
|
||||
alertmanagerHandler,
|
||||
traceDetailHandler,
|
||||
rulerHandler,
|
||||
)
|
||||
})
|
||||
@@ -141,6 +149,7 @@ func newProvider(
|
||||
dashboardModule dashboard.Module,
|
||||
dashboardHandler dashboard.Handler,
|
||||
metricsExplorerHandler metricsexplorer.Handler,
|
||||
infraMonitoringHandler inframonitoring.Handler,
|
||||
gatewayHandler gateway.Handler,
|
||||
fieldsHandler fields.Handler,
|
||||
authzHandler authz.Handler,
|
||||
@@ -152,6 +161,7 @@ func newProvider(
|
||||
cloudIntegrationHandler cloudintegration.Handler,
|
||||
ruleStateHistoryHandler rulestatehistory.Handler,
|
||||
alertmanagerHandler alertmanager.Handler,
|
||||
traceDetailHandler tracedetail.Handler,
|
||||
rulerHandler ruler.Handler,
|
||||
) (apiserver.APIServer, error) {
|
||||
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/apiserver/signozapiserver")
|
||||
@@ -172,6 +182,7 @@ func newProvider(
|
||||
dashboardModule: dashboardModule,
|
||||
dashboardHandler: dashboardHandler,
|
||||
metricsExplorerHandler: metricsExplorerHandler,
|
||||
infraMonitoringHandler: infraMonitoringHandler,
|
||||
gatewayHandler: gatewayHandler,
|
||||
fieldsHandler: fieldsHandler,
|
||||
authzHandler: authzHandler,
|
||||
@@ -183,6 +194,7 @@ func newProvider(
|
||||
cloudIntegrationHandler: cloudIntegrationHandler,
|
||||
ruleStateHistoryHandler: ruleStateHistoryHandler,
|
||||
alertmanagerHandler: alertmanagerHandler,
|
||||
traceDetailHandler: traceDetailHandler,
|
||||
rulerHandler: rulerHandler,
|
||||
}
|
||||
|
||||
@@ -240,6 +252,10 @@ func (provider *provider) AddToRouter(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := provider.addInfraMonitoringRoutes(router); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := provider.addGatewayRoutes(router); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -288,6 +304,10 @@ func (provider *provider) AddToRouter(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := provider.addTraceDetailRoutes(router); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := provider.addRulerRoutes(router); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ func (provider *provider) addRoleRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/roles/{id}/relation/{relation}/objects", handler.New(provider.authZ.AdminAccess(provider.authzHandler.GetObjects), handler.OpenAPIDef{
|
||||
if err := router.Handle("/api/v1/roles/{id}/relations/{relation}/objects", handler.New(provider.authZ.AdminAccess(provider.authzHandler.GetObjects), handler.OpenAPIDef{
|
||||
ID: "GetObjects",
|
||||
Tags: []string{"role"},
|
||||
Summary: "Get objects for a role by relation",
|
||||
@@ -95,7 +95,7 @@ func (provider *provider) addRoleRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/roles/{id}/relation/{relation}/objects", handler.New(provider.authZ.AdminAccess(provider.authzHandler.PatchObjects), handler.OpenAPIDef{
|
||||
if err := router.Handle("/api/v1/roles/{id}/relations/{relation}/objects", handler.New(provider.authZ.AdminAccess(provider.authzHandler.PatchObjects), handler.OpenAPIDef{
|
||||
ID: "PatchObjects",
|
||||
Tags: []string{"role"},
|
||||
Summary: "Patch objects for a role by relation",
|
||||
|
||||
33
pkg/apiserver/signozapiserver/tracedetail.go
Normal file
33
pkg/apiserver/signozapiserver/tracedetail.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package signozapiserver
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/http/handler"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/tracedetailtypes"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
func (provider *provider) addTraceDetailRoutes(router *mux.Router) error {
|
||||
if err := router.Handle("/api/v3/traces/{traceID}/waterfall", handler.New(
|
||||
provider.authZ.ViewAccess(provider.traceDetailHandler.GetWaterfall),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetWaterfall",
|
||||
Tags: []string{"tracedetail"},
|
||||
Summary: "Get waterfall view for a trace",
|
||||
Description: "Returns the waterfall view of spans for a given trace ID with tree structure, metadata, and windowed pagination",
|
||||
Request: new(tracedetailtypes.PostableWaterfall),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(tracedetailtypes.GettableWaterfallTrace),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
},
|
||||
)).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -26,7 +26,7 @@ type AuthZ interface {
|
||||
Write(context.Context, []*openfgav1.TupleKey, []*openfgav1.TupleKey) error
|
||||
|
||||
// Lists the selectors for objects assigned to subject (s) with relation (r) on resource (s)
|
||||
ListObjects(context.Context, string, authtypes.Relation, authtypes.Typeable) ([]*authtypes.Object, error)
|
||||
ListObjects(context.Context, string, authtypes.Relation, authtypes.Type) ([]*authtypes.Object, error)
|
||||
|
||||
// Creates the role.
|
||||
Create(context.Context, valuer.UUID, *authtypes.Role) error
|
||||
@@ -78,8 +78,14 @@ type AuthZ interface {
|
||||
|
||||
// Bootstrap managed roles transactions and user assignments
|
||||
CreateManagedUserRoleTransactions(context.Context, valuer.UUID, valuer.UUID) error
|
||||
|
||||
// ReadTuples reads tuples from the authorization server matching the given tuple key filter.
|
||||
ReadTuples(context.Context, *openfgav1.ReadRequestTupleKey) ([]*openfgav1.TupleKey, error)
|
||||
}
|
||||
|
||||
// OnBeforeRoleDelete is a callback invoked before a role is deleted.
|
||||
type OnBeforeRoleDelete func(context.Context, valuer.UUID, valuer.UUID) error
|
||||
|
||||
type RegisterTypeable interface {
|
||||
MustGetTypeables() []authtypes.Typeable
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ func NewSqlAuthzStore(sqlstore sqlstore.SQLStore) authtypes.RoleStore {
|
||||
return &store{sqlstore: sqlstore}
|
||||
}
|
||||
|
||||
func (store *store) Create(ctx context.Context, role *authtypes.StorableRole) error {
|
||||
func (store *store) Create(ctx context.Context, role *authtypes.Role) error {
|
||||
_, err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
@@ -32,8 +32,8 @@ func (store *store) Create(ctx context.Context, role *authtypes.StorableRole) er
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *store) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*authtypes.StorableRole, error) {
|
||||
role := new(authtypes.StorableRole)
|
||||
func (store *store) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*authtypes.Role, error) {
|
||||
role := new(authtypes.Role)
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
@@ -49,8 +49,8 @@ func (store *store) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID)
|
||||
return role, nil
|
||||
}
|
||||
|
||||
func (store *store) GetByOrgIDAndName(ctx context.Context, orgID valuer.UUID, name string) (*authtypes.StorableRole, error) {
|
||||
role := new(authtypes.StorableRole)
|
||||
func (store *store) GetByOrgIDAndName(ctx context.Context, orgID valuer.UUID, name string) (*authtypes.Role, error) {
|
||||
role := new(authtypes.Role)
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
@@ -66,8 +66,8 @@ func (store *store) GetByOrgIDAndName(ctx context.Context, orgID valuer.UUID, na
|
||||
return role, nil
|
||||
}
|
||||
|
||||
func (store *store) List(ctx context.Context, orgID valuer.UUID) ([]*authtypes.StorableRole, error) {
|
||||
roles := make([]*authtypes.StorableRole, 0)
|
||||
func (store *store) List(ctx context.Context, orgID valuer.UUID) ([]*authtypes.Role, error) {
|
||||
roles := make([]*authtypes.Role, 0)
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
@@ -82,8 +82,8 @@ func (store *store) List(ctx context.Context, orgID valuer.UUID) ([]*authtypes.S
|
||||
return roles, nil
|
||||
}
|
||||
|
||||
func (store *store) ListByOrgIDAndNames(ctx context.Context, orgID valuer.UUID, names []string) ([]*authtypes.StorableRole, error) {
|
||||
roles := make([]*authtypes.StorableRole, 0)
|
||||
func (store *store) ListByOrgIDAndNames(ctx context.Context, orgID valuer.UUID, names []string) ([]*authtypes.Role, error) {
|
||||
roles := make([]*authtypes.Role, 0)
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
@@ -103,8 +103,8 @@ func (store *store) ListByOrgIDAndNames(ctx context.Context, orgID valuer.UUID,
|
||||
return roles, nil
|
||||
}
|
||||
|
||||
func (store *store) ListByOrgIDAndIDs(ctx context.Context, orgID valuer.UUID, ids []valuer.UUID) ([]*authtypes.StorableRole, error) {
|
||||
roles := make([]*authtypes.StorableRole, 0)
|
||||
func (store *store) ListByOrgIDAndIDs(ctx context.Context, orgID valuer.UUID, ids []valuer.UUID) ([]*authtypes.Role, error) {
|
||||
roles := make([]*authtypes.Role, 0)
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
@@ -124,7 +124,7 @@ func (store *store) ListByOrgIDAndIDs(ctx context.Context, orgID valuer.UUID, id
|
||||
return roles, nil
|
||||
}
|
||||
|
||||
func (store *store) Update(ctx context.Context, orgID valuer.UUID, role *authtypes.StorableRole) error {
|
||||
func (store *store) Update(ctx context.Context, orgID valuer.UUID, role *authtypes.Role) error {
|
||||
_, err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
@@ -145,7 +145,7 @@ func (store *store) Delete(ctx context.Context, orgID valuer.UUID, id valuer.UUI
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewDelete().
|
||||
Model(new(authtypes.StorableRole)).
|
||||
Model(new(authtypes.Role)).
|
||||
Where("org_id = ?", orgID).
|
||||
Where("id = ?", id).
|
||||
Exec(ctx)
|
||||
|
||||
@@ -4,14 +4,30 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
)
|
||||
|
||||
type Config struct{}
|
||||
type Config struct {
|
||||
// Provider is the name of the authorization provider to use.
|
||||
Provider string `mapstructure:"provider"`
|
||||
|
||||
// OpenFGA is the configuration specific to the OpenFGA authorization provider.
|
||||
OpenFGA OpenFGAConfig `mapstructure:"openfga"`
|
||||
}
|
||||
|
||||
type OpenFGAConfig struct {
|
||||
// MaxTuplesPerWrite is the maximum number of tuples to include in a single write call.
|
||||
MaxTuplesPerWrite int `mapstructure:"max_tuples_per_write"`
|
||||
}
|
||||
|
||||
func NewConfigFactory() factory.ConfigFactory {
|
||||
return factory.NewConfigFactory(factory.MustNewName("authz"), newConfig)
|
||||
}
|
||||
|
||||
func newConfig() factory.Config {
|
||||
return Config{}
|
||||
return &Config{
|
||||
Provider: "openfga",
|
||||
OpenFGA: OpenFGAConfig{
|
||||
MaxTuplesPerWrite: 100,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (c Config) Validate() error {
|
||||
|
||||
@@ -68,68 +68,32 @@ func (provider *provider) Write(ctx context.Context, additions []*openfgav1.Tupl
|
||||
return provider.server.Write(ctx, additions, deletions)
|
||||
}
|
||||
|
||||
func (provider *provider) ListObjects(ctx context.Context, subject string, relation authtypes.Relation, typeable authtypes.Typeable) ([]*authtypes.Object, error) {
|
||||
return provider.server.ListObjects(ctx, subject, relation, typeable)
|
||||
func (provider *provider) ReadTuples(ctx context.Context, tupleKey *openfgav1.ReadRequestTupleKey) ([]*openfgav1.TupleKey, error) {
|
||||
return provider.server.ReadTuples(ctx, tupleKey)
|
||||
}
|
||||
|
||||
func (provider *provider) ListObjects(ctx context.Context, subject string, relation authtypes.Relation, objectType authtypes.Type) ([]*authtypes.Object, error) {
|
||||
return provider.server.ListObjects(ctx, subject, relation, objectType)
|
||||
}
|
||||
|
||||
func (provider *provider) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*authtypes.Role, error) {
|
||||
storableRole, err := provider.store.Get(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return authtypes.NewRoleFromStorableRole(storableRole), nil
|
||||
return provider.store.Get(ctx, orgID, id)
|
||||
}
|
||||
|
||||
func (provider *provider) GetByOrgIDAndName(ctx context.Context, orgID valuer.UUID, name string) (*authtypes.Role, error) {
|
||||
storableRole, err := provider.store.GetByOrgIDAndName(ctx, orgID, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return authtypes.NewRoleFromStorableRole(storableRole), nil
|
||||
return provider.store.GetByOrgIDAndName(ctx, orgID, name)
|
||||
}
|
||||
|
||||
func (provider *provider) List(ctx context.Context, orgID valuer.UUID) ([]*authtypes.Role, error) {
|
||||
storableRoles, err := provider.store.List(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
roles := make([]*authtypes.Role, len(storableRoles))
|
||||
for idx, storableRole := range storableRoles {
|
||||
roles[idx] = authtypes.NewRoleFromStorableRole(storableRole)
|
||||
}
|
||||
|
||||
return roles, nil
|
||||
return provider.store.List(ctx, orgID)
|
||||
}
|
||||
|
||||
func (provider *provider) ListByOrgIDAndNames(ctx context.Context, orgID valuer.UUID, names []string) ([]*authtypes.Role, error) {
|
||||
storableRoles, err := provider.store.ListByOrgIDAndNames(ctx, orgID, names)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
roles := make([]*authtypes.Role, len(storableRoles))
|
||||
for idx, storable := range storableRoles {
|
||||
roles[idx] = authtypes.NewRoleFromStorableRole(storable)
|
||||
}
|
||||
|
||||
return roles, nil
|
||||
return provider.store.ListByOrgIDAndNames(ctx, orgID, names)
|
||||
}
|
||||
|
||||
func (provider *provider) ListByOrgIDAndIDs(ctx context.Context, orgID valuer.UUID, ids []valuer.UUID) ([]*authtypes.Role, error) {
|
||||
storableRoles, err := provider.store.ListByOrgIDAndIDs(ctx, orgID, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
roles := make([]*authtypes.Role, len(storableRoles))
|
||||
for idx, storable := range storableRoles {
|
||||
roles[idx] = authtypes.NewRoleFromStorableRole(storable)
|
||||
}
|
||||
|
||||
return roles, nil
|
||||
return provider.store.ListByOrgIDAndIDs(ctx, orgID, ids)
|
||||
}
|
||||
|
||||
func (provider *provider) Grant(ctx context.Context, orgID valuer.UUID, names []string, subject string) error {
|
||||
@@ -197,7 +161,7 @@ func (provider *provider) Revoke(ctx context.Context, orgID valuer.UUID, names [
|
||||
func (provider *provider) CreateManagedRoles(ctx context.Context, _ valuer.UUID, managedRoles []*authtypes.Role) error {
|
||||
err := provider.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
for _, role := range managedRoles {
|
||||
err := provider.store.Create(ctx, authtypes.NewStorableRoleFromRole(role))
|
||||
err := provider.store.Create(ctx, role)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -265,17 +265,45 @@ func (server *Server) Write(ctx context.Context, additions []*openfgav1.TupleKey
|
||||
return nil
|
||||
}
|
||||
|
||||
func (server *Server) ListObjects(ctx context.Context, subject string, relation authtypes.Relation, typeable authtypes.Typeable) ([]*authtypes.Object, error) {
|
||||
func (server *Server) ReadTuples(ctx context.Context, tupleKey *openfgav1.ReadRequestTupleKey) ([]*openfgav1.TupleKey, error) {
|
||||
storeID, _ := server.getStoreIDandModelID()
|
||||
var tuples []*openfgav1.TupleKey
|
||||
continuationToken := ""
|
||||
|
||||
for {
|
||||
response, err := server.openfgaServer.Read(ctx, &openfgav1.ReadRequest{
|
||||
StoreId: storeID,
|
||||
TupleKey: tupleKey,
|
||||
ContinuationToken: continuationToken,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, errors.TypeInternal, authtypes.ErrCodeAuthZUnavailable, "failed to read tuples from authorization server")
|
||||
}
|
||||
|
||||
for _, tuple := range response.Tuples {
|
||||
tuples = append(tuples, tuple.Key)
|
||||
}
|
||||
|
||||
if response.ContinuationToken == "" {
|
||||
break
|
||||
}
|
||||
continuationToken = response.ContinuationToken
|
||||
}
|
||||
|
||||
return tuples, nil
|
||||
}
|
||||
|
||||
func (server *Server) ListObjects(ctx context.Context, subject string, relation authtypes.Relation, objectType authtypes.Type) ([]*authtypes.Object, error) {
|
||||
storeID, modelID := server.getStoreIDandModelID()
|
||||
response, err := server.openfgaServer.ListObjects(ctx, &openfgav1.ListObjectsRequest{
|
||||
StoreId: storeID,
|
||||
AuthorizationModelId: modelID,
|
||||
User: subject,
|
||||
Relation: relation.StringValue(),
|
||||
Type: typeable.Type().StringValue(),
|
||||
Type: objectType.StringValue(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, errors.TypeInternal, authtypes.ErrCodeAuthZUnavailable, "cannot list objects for subject %s with relation %s for type %s", subject, relation.StringValue(), typeable.Type().StringValue())
|
||||
return nil, errors.Wrapf(err, errors.TypeInternal, authtypes.ErrCodeAuthZUnavailable, "cannot list objects for subject %s with relation %s for type %s", subject, relation.StringValue(), objectType.StringValue())
|
||||
}
|
||||
|
||||
return authtypes.MustNewObjectsFromStringSlice(response.Objects), nil
|
||||
|
||||
@@ -373,7 +373,13 @@ func (handler *handler) UpdateService(rw http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// update or create service
|
||||
if svc.CloudIntegrationService == nil {
|
||||
cloudIntegrationService := cloudintegrationtypes.NewCloudIntegrationService(serviceID, cloudIntegrationID, req.Config)
|
||||
var cloudIntegrationService *cloudintegrationtypes.CloudIntegrationService
|
||||
cloudIntegrationService, err = cloudintegrationtypes.NewCloudIntegrationService(serviceID, cloudIntegrationID, provider, req.Config)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = handler.module.CreateService(ctx, orgID, cloudIntegrationService, provider)
|
||||
} else {
|
||||
err = svc.CloudIntegrationService.Update(provider, serviceID, req.Config)
|
||||
|
||||
33
pkg/modules/inframonitoring/config.go
Normal file
33
pkg/modules/inframonitoring/config.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package inframonitoring
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
TelemetryStore TelemetryStoreConfig `mapstructure:"telemetrystore"`
|
||||
}
|
||||
|
||||
type TelemetryStoreConfig struct {
|
||||
Threads int `mapstructure:"threads"`
|
||||
}
|
||||
|
||||
func NewConfigFactory() factory.ConfigFactory {
|
||||
return factory.NewConfigFactory(factory.MustNewName("inframonitoring"), newConfig)
|
||||
}
|
||||
|
||||
func newConfig() factory.Config {
|
||||
return Config{
|
||||
TelemetryStore: TelemetryStoreConfig{
|
||||
Threads: 8,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (c Config) Validate() error {
|
||||
if c.TelemetryStore.Threads <= 0 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "inframonitoring.telemetrystore.threads must be positive, got %d", c.TelemetryStore.Threads)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
47
pkg/modules/inframonitoring/implinframonitoring/handler.go
Normal file
47
pkg/modules/inframonitoring/implinframonitoring/handler.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package implinframonitoring
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/http/binding"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/SigNoz/signoz/pkg/modules/inframonitoring"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/inframonitoringtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type handler struct {
|
||||
module inframonitoring.Module
|
||||
}
|
||||
|
||||
// NewHandler returns an inframonitoring.Handler implementation.
|
||||
func NewHandler(m inframonitoring.Module) inframonitoring.Handler {
|
||||
return &handler{
|
||||
module: m,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *handler) ListHosts(rw http.ResponseWriter, req *http.Request) {
|
||||
claims, err := authtypes.ClaimsFromContext(req.Context())
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
orgID := valuer.MustNewUUID(claims.OrgID)
|
||||
|
||||
var parsedReq inframonitoringtypes.PostableHosts
|
||||
if err := binding.JSON.BindBody(req.Body, &parsedReq); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.module.ListHosts(req.Context(), orgID, &parsedReq)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, result)
|
||||
}
|
||||
573
pkg/modules/inframonitoring/implinframonitoring/helpers.go
Normal file
573
pkg/modules/inframonitoring/implinframonitoring/helpers.go
Normal file
@@ -0,0 +1,573 @@
|
||||
package implinframonitoring
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
|
||||
"github.com/SigNoz/signoz/pkg/types/metrictypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/huandu/go-sqlbuilder"
|
||||
)
|
||||
|
||||
// quoteIdentifier wraps s in backticks for use as a ClickHouse identifier,
|
||||
// escaping any embedded backticks by doubling them.
|
||||
func quoteIdentifier(s string) string {
|
||||
return fmt.Sprintf("`%s`", strings.ReplaceAll(s, "`", "``"))
|
||||
}
|
||||
|
||||
func isKeyInGroupByAttrs(groupByAttrs []qbtypes.GroupByKey, key string) bool {
|
||||
for _, groupBy := range groupByAttrs {
|
||||
if groupBy.Name == key {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func mergeFilterExpressions(queryFilterExpr, reqFilterExpr string) string {
|
||||
queryFilterExpr = strings.TrimSpace(queryFilterExpr)
|
||||
reqFilterExpr = strings.TrimSpace(reqFilterExpr)
|
||||
if queryFilterExpr == "" {
|
||||
return reqFilterExpr
|
||||
}
|
||||
if reqFilterExpr == "" {
|
||||
return queryFilterExpr
|
||||
}
|
||||
return fmt.Sprintf("(%s) AND (%s)", queryFilterExpr, reqFilterExpr)
|
||||
}
|
||||
|
||||
// compositeKeyFromList builds a composite key by joining the given parts
|
||||
// with a null byte separator. This is the canonical way to construct
|
||||
// composite keys for group identification across the infra monitoring module.
|
||||
func compositeKeyFromList(parts []string) string {
|
||||
return strings.Join(parts, "\x00")
|
||||
}
|
||||
|
||||
// compositeKeyFromLabels builds a composite key from a label map by extracting
|
||||
// the value for each groupBy key in order and joining them via compositeKeyFromList.
|
||||
func compositeKeyFromLabels(labels map[string]string, groupBy []qbtypes.GroupByKey) string {
|
||||
parts := make([]string, len(groupBy))
|
||||
for i, key := range groupBy {
|
||||
parts[i] = labels[key.Name]
|
||||
}
|
||||
return compositeKeyFromList(parts)
|
||||
}
|
||||
|
||||
// parseAndSortGroups extracts group label maps from a ScalarData response and
|
||||
// sorts them by the ranking query's aggregation value.
|
||||
func parseAndSortGroups(
|
||||
resp *qbtypes.QueryRangeResponse,
|
||||
rankingQueryName string,
|
||||
groupBy []qbtypes.GroupByKey,
|
||||
direction qbtypes.OrderDirection,
|
||||
) []rankedGroup {
|
||||
if resp == nil || len(resp.Data.Results) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Find the ScalarData that contains the ranking column.
|
||||
var sd *qbtypes.ScalarData
|
||||
for _, r := range resp.Data.Results {
|
||||
candidate, ok := r.(*qbtypes.ScalarData)
|
||||
if !ok || candidate == nil {
|
||||
continue
|
||||
}
|
||||
for _, col := range candidate.Columns {
|
||||
if col.Type == qbtypes.ColumnTypeAggregation && col.QueryName == rankingQueryName {
|
||||
sd = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
if sd != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if sd == nil || len(sd.Data) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
groupColIndices := make(map[string]int)
|
||||
rankingColIdx := -1
|
||||
for i, col := range sd.Columns {
|
||||
if col.Type == qbtypes.ColumnTypeGroup {
|
||||
groupColIndices[col.Name] = i
|
||||
}
|
||||
if col.Type == qbtypes.ColumnTypeAggregation && col.QueryName == rankingQueryName {
|
||||
rankingColIdx = i
|
||||
}
|
||||
}
|
||||
if rankingColIdx == -1 {
|
||||
return nil
|
||||
}
|
||||
|
||||
groups := make([]rankedGroup, 0, len(sd.Data))
|
||||
for _, row := range sd.Data {
|
||||
labels := make(map[string]string, len(groupBy))
|
||||
for _, key := range groupBy {
|
||||
if idx, ok := groupColIndices[key.Name]; ok && idx < len(row) {
|
||||
labels[key.Name] = fmt.Sprintf("%v", row[idx])
|
||||
}
|
||||
}
|
||||
var value float64
|
||||
if rankingColIdx < len(row) {
|
||||
if v, ok := row[rankingColIdx].(float64); ok {
|
||||
value = v
|
||||
}
|
||||
}
|
||||
groups = append(groups, rankedGroup{
|
||||
labels: labels,
|
||||
value: value,
|
||||
compositeKey: compositeKeyFromLabels(labels, groupBy),
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(groups, func(i, j int) bool {
|
||||
if groups[i].value != groups[j].value {
|
||||
if direction == qbtypes.OrderDirectionAsc {
|
||||
return groups[i].value < groups[j].value
|
||||
}
|
||||
return groups[i].value > groups[j].value
|
||||
}
|
||||
return groups[i].compositeKey < groups[j].compositeKey
|
||||
})
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
// paginateWithBackfill returns the page of groups for [offset, offset+limit).
|
||||
// The virtual sorted list is: metric-ranked groups first, then metadata-only
|
||||
// groups (those in metadataMap but not in metric results) sorted alphabetically.
|
||||
func paginateWithBackfill(
|
||||
metricGroups []rankedGroup,
|
||||
metadataMap map[string]map[string]string,
|
||||
groupBy []qbtypes.GroupByKey,
|
||||
offset, limit int,
|
||||
) []map[string]string {
|
||||
metricKeySet := make(map[string]bool, len(metricGroups))
|
||||
for _, g := range metricGroups {
|
||||
metricKeySet[g.compositeKey] = true
|
||||
}
|
||||
|
||||
metadataOnlyKeys := make([]string, 0)
|
||||
for compositeKey := range metadataMap {
|
||||
if !metricKeySet[compositeKey] {
|
||||
metadataOnlyKeys = append(metadataOnlyKeys, compositeKey)
|
||||
}
|
||||
}
|
||||
sort.Strings(metadataOnlyKeys)
|
||||
|
||||
totalMetric := len(metricGroups)
|
||||
totalAll := totalMetric + len(metadataOnlyKeys)
|
||||
|
||||
end := offset + limit
|
||||
if end > totalAll {
|
||||
end = totalAll
|
||||
}
|
||||
if offset >= totalAll {
|
||||
return nil
|
||||
}
|
||||
|
||||
pageGroups := make([]map[string]string, 0, end-offset)
|
||||
for i := offset; i < end; i++ {
|
||||
if i < totalMetric {
|
||||
pageGroups = append(pageGroups, metricGroups[i].labels)
|
||||
} else {
|
||||
compositeKey := metadataOnlyKeys[i-totalMetric]
|
||||
attrs := metadataMap[compositeKey]
|
||||
labels := make(map[string]string, len(groupBy))
|
||||
for _, key := range groupBy {
|
||||
labels[key.Name] = attrs[key.Name]
|
||||
}
|
||||
pageGroups = append(pageGroups, labels)
|
||||
}
|
||||
}
|
||||
return pageGroups
|
||||
}
|
||||
|
||||
// buildPageGroupsFilterExpr builds a filter expression that restricts results
|
||||
// to the given page of groups via IN clauses.
|
||||
// Returns e.g. "host.name IN ('h1','h2') AND os.type IN ('linux','windows')".
|
||||
func buildPageGroupsFilterExpr(pageGroups []map[string]string) string {
|
||||
groupValues := make(map[string][]string)
|
||||
for _, labels := range pageGroups {
|
||||
for k, v := range labels {
|
||||
groupValues[k] = append(groupValues[k], v)
|
||||
}
|
||||
}
|
||||
|
||||
inClauses := make([]string, 0, len(groupValues))
|
||||
for key, values := range groupValues {
|
||||
quoted := make([]string, len(values))
|
||||
for i, v := range values {
|
||||
quoted[i] = fmt.Sprintf("'%s'", v)
|
||||
}
|
||||
inClauses = append(inClauses, fmt.Sprintf("%s IN (%s)", key, strings.Join(quoted, ", ")))
|
||||
}
|
||||
return strings.Join(inClauses, " AND ")
|
||||
}
|
||||
|
||||
// buildFullQueryRequest creates a QueryRangeRequest for all metrics,
|
||||
// restricted to the given page of groups via an IN filter.
|
||||
// Accepts primitive fields so it can be reused across different v2 APIs
|
||||
// (hosts, pods, etc.).
|
||||
func buildFullQueryRequest(
|
||||
start int64,
|
||||
end int64,
|
||||
filterExpr string,
|
||||
groupBy []qbtypes.GroupByKey,
|
||||
pageGroups []map[string]string,
|
||||
tableListQuery *qbtypes.QueryRangeRequest,
|
||||
) *qbtypes.QueryRangeRequest {
|
||||
inFilterExpr := buildPageGroupsFilterExpr(pageGroups)
|
||||
|
||||
fullReq := &qbtypes.QueryRangeRequest{
|
||||
Start: uint64(start),
|
||||
End: uint64(end),
|
||||
RequestType: qbtypes.RequestTypeScalar,
|
||||
CompositeQuery: qbtypes.CompositeQuery{
|
||||
Queries: make([]qbtypes.QueryEnvelope, 0, len(tableListQuery.CompositeQuery.Queries)),
|
||||
},
|
||||
}
|
||||
|
||||
for _, envelope := range tableListQuery.CompositeQuery.Queries {
|
||||
copied := envelope
|
||||
if copied.Type == qbtypes.QueryTypeBuilder {
|
||||
existingExpr := ""
|
||||
if f := copied.GetFilter(); f != nil {
|
||||
existingExpr = f.Expression
|
||||
}
|
||||
merged := mergeFilterExpressions(existingExpr, filterExpr)
|
||||
merged = mergeFilterExpressions(merged, inFilterExpr)
|
||||
copied.SetFilter(&qbtypes.Filter{Expression: merged})
|
||||
copied.SetGroupBy(groupBy)
|
||||
}
|
||||
fullReq.CompositeQuery.Queries = append(fullReq.CompositeQuery.Queries, copied)
|
||||
}
|
||||
|
||||
return fullReq
|
||||
}
|
||||
|
||||
// parseFullQueryResponse extracts per-group metric values from the full
|
||||
// composite query response. Returns compositeKey -> (queryName -> value).
|
||||
// Each enabled query/formula produces its own ScalarData entry in Results,
|
||||
// so we iterate over all of them and merge metrics per composite key.
|
||||
func parseFullQueryResponse(
|
||||
resp *qbtypes.QueryRangeResponse,
|
||||
groupBy []qbtypes.GroupByKey,
|
||||
) map[string]map[string]float64 {
|
||||
result := make(map[string]map[string]float64)
|
||||
if resp == nil || len(resp.Data.Results) == 0 {
|
||||
return result
|
||||
}
|
||||
|
||||
for _, r := range resp.Data.Results {
|
||||
sd, ok := r.(*qbtypes.ScalarData)
|
||||
if !ok || sd == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
groupColIndices := make(map[string]int)
|
||||
aggCols := make(map[int]string) // col index -> query name
|
||||
for i, col := range sd.Columns {
|
||||
if col.Type == qbtypes.ColumnTypeGroup {
|
||||
groupColIndices[col.Name] = i
|
||||
}
|
||||
if col.Type == qbtypes.ColumnTypeAggregation {
|
||||
aggCols[i] = col.QueryName
|
||||
}
|
||||
}
|
||||
|
||||
for _, row := range sd.Data {
|
||||
labels := make(map[string]string, len(groupBy))
|
||||
for _, key := range groupBy {
|
||||
if idx, ok := groupColIndices[key.Name]; ok && idx < len(row) {
|
||||
labels[key.Name] = fmt.Sprintf("%v", row[idx])
|
||||
}
|
||||
}
|
||||
compositeKey := compositeKeyFromLabels(labels, groupBy)
|
||||
|
||||
if result[compositeKey] == nil {
|
||||
result[compositeKey] = make(map[string]float64)
|
||||
}
|
||||
for idx, queryName := range aggCols {
|
||||
if idx < len(row) {
|
||||
if v, ok := row[idx].(float64); ok {
|
||||
result[compositeKey][queryName] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// buildSamplesTblFingerprintSubQuery returns a SelectBuilder that selects distinct fingerprints
|
||||
// from the samples table for the given metric names andtime range.
|
||||
func (m *module) buildSamplesTblFingerprintSubQuery(metricNames []string, startMs, endMs int64) *sqlbuilder.SelectBuilder {
|
||||
samplesTableName := telemetrymetrics.WhichSamplesTableToUse(
|
||||
uint64(startMs), uint64(endMs),
|
||||
metrictypes.UnspecifiedType,
|
||||
metrictypes.TimeAggregationUnspecified,
|
||||
nil,
|
||||
)
|
||||
localSamplesTable := strings.TrimPrefix(samplesTableName, "distributed_")
|
||||
fpSB := sqlbuilder.NewSelectBuilder()
|
||||
fpSB.Select("DISTINCT fingerprint")
|
||||
fpSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, localSamplesTable))
|
||||
fpSB.Where(
|
||||
fpSB.In("metric_name", sqlbuilder.List(metricNames)),
|
||||
fpSB.GE("unix_milli", startMs),
|
||||
fpSB.L("unix_milli", endMs),
|
||||
)
|
||||
return fpSB
|
||||
}
|
||||
|
||||
func (m *module) buildFilterClause(ctx context.Context, filter *qbtypes.Filter, startMillis, endMillis int64) (*sqlbuilder.WhereClause, error) {
|
||||
expression := ""
|
||||
if filter != nil {
|
||||
expression = strings.TrimSpace(filter.Expression)
|
||||
}
|
||||
if expression == "" {
|
||||
return sqlbuilder.NewWhereClause(), nil
|
||||
}
|
||||
|
||||
whereClauseSelectors := querybuilder.QueryStringToKeysSelectors(expression)
|
||||
for idx := range whereClauseSelectors {
|
||||
whereClauseSelectors[idx].Signal = telemetrytypes.SignalMetrics
|
||||
whereClauseSelectors[idx].SelectorMatchType = telemetrytypes.FieldSelectorMatchTypeExact
|
||||
}
|
||||
|
||||
keys, _, err := m.telemetryMetadataStore.GetKeysMulti(ctx, whereClauseSelectors)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
opts := querybuilder.FilterExprVisitorOpts{
|
||||
Context: ctx,
|
||||
Logger: m.logger,
|
||||
FieldMapper: m.fieldMapper,
|
||||
ConditionBuilder: m.condBuilder,
|
||||
FullTextColumn: &telemetrytypes.TelemetryFieldKey{Name: "metric_name", FieldContext: telemetrytypes.FieldContextMetric},
|
||||
FieldKeys: keys,
|
||||
StartNs: querybuilder.ToNanoSecs(uint64(startMillis)),
|
||||
EndNs: querybuilder.ToNanoSecs(uint64(endMillis)),
|
||||
}
|
||||
|
||||
whereClause, err := querybuilder.PrepareWhereClause(expression, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if whereClause == nil || whereClause.WhereClause == nil {
|
||||
return sqlbuilder.NewWhereClause(), nil
|
||||
}
|
||||
|
||||
return whereClause.WhereClause, nil
|
||||
}
|
||||
|
||||
// NOTE: this method is not specific to infra monitoring — it queries attributes_metadata generically.
|
||||
// Consider moving to telemetryMetaStore when a second use case emerges.
|
||||
//
|
||||
// getMetricsExistenceAndEarliestTime checks which of the given metric names have been
|
||||
// reported. It returns a list of missing metrics (those not found or with zero count)
|
||||
// and the earliest first-reported timestamp across all present metrics.
|
||||
// When all metrics are missing, minFirstReportedUnixMilli is 0.
|
||||
// TODO(nikhilmantri0902, srikanthccv): This method was designed this way because querier errors if any of the metrics
|
||||
// in the querier list was never sent, the QueryRange call throws not found error. Modify this method, if QueryRange
|
||||
// behaviour changes towards this.
|
||||
func (m *module) getMetricsExistenceAndEarliestTime(ctx context.Context, metricNames []string) ([]string, uint64, error) {
|
||||
if len(metricNames) == 0 {
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("metric_name", "count(*) AS cnt", "min(first_reported_unix_milli) AS min_first_reported")
|
||||
sb.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.AttributesMetadataTableName))
|
||||
sb.Where(sb.In("metric_name", sqlbuilder.List(metricNames)))
|
||||
sb.GroupBy("metric_name")
|
||||
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
rows, err := m.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type metricInfo struct {
|
||||
count uint64
|
||||
minFirstReported uint64
|
||||
}
|
||||
found := make(map[string]metricInfo, len(metricNames))
|
||||
|
||||
for rows.Next() {
|
||||
var name string
|
||||
var cnt, minFR uint64
|
||||
if err := rows.Scan(&name, &cnt, &minFR); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
found[name] = metricInfo{count: cnt, minFirstReported: minFR}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
var missingMetrics []string
|
||||
var globalMinFirstReported uint64
|
||||
for _, name := range metricNames {
|
||||
info, ok := found[name]
|
||||
if !ok || info.count == 0 {
|
||||
missingMetrics = append(missingMetrics, name)
|
||||
continue
|
||||
}
|
||||
if globalMinFirstReported == 0 || info.minFirstReported < globalMinFirstReported {
|
||||
globalMinFirstReported = info.minFirstReported
|
||||
}
|
||||
}
|
||||
|
||||
return missingMetrics, globalMinFirstReported, nil
|
||||
}
|
||||
|
||||
// getMetadata fetches the latest values of additionalCols for each unique combination of groupBy keys,
|
||||
// within the given time range and metric names. It uses argMax(tuple(...), unix_milli) to ensure
|
||||
// we always pick attribute values from the latest timestamp for each group.
|
||||
// The returned map has a composite key of groupBy column values joined by "\x00" (null byte),
|
||||
// mapping to a flat map of attr_name -> attr_value (includes both groupBy and additional cols).
|
||||
func (m *module) getMetadata(
|
||||
ctx context.Context,
|
||||
metricNames []string,
|
||||
groupBy []qbtypes.GroupByKey,
|
||||
additionalCols []string,
|
||||
filter *qbtypes.Filter,
|
||||
startMs, endMs int64,
|
||||
) (map[string]map[string]string, error) {
|
||||
if len(metricNames) == 0 {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "metricNames must not be empty")
|
||||
}
|
||||
if len(groupBy) == 0 {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "groupBy must not be empty")
|
||||
}
|
||||
|
||||
// Pick the optimal timeseries table based on time range; also get adjusted start.
|
||||
adjustedStart, adjustedEnd, distributedTableName, _ := telemetrymetrics.WhichTSTableToUse(
|
||||
uint64(startMs), uint64(endMs), nil,
|
||||
)
|
||||
|
||||
fpSB := m.buildSamplesTblFingerprintSubQuery(metricNames, startMs, endMs)
|
||||
|
||||
// Flatten groupBy keys to string names for SQL expressions and result scanning.
|
||||
groupByCols := make([]string, len(groupBy))
|
||||
for i, key := range groupBy {
|
||||
groupByCols[i] = key.Name
|
||||
}
|
||||
allCols := append(groupByCols, additionalCols...)
|
||||
|
||||
// --- Build inner query ---
|
||||
innerSB := sqlbuilder.NewSelectBuilder()
|
||||
|
||||
// Inner SELECT columns: JSONExtractString for each groupBy col + argMax(tuple(...)) for additional cols
|
||||
innerSelectCols := make([]string, 0, len(groupByCols)+1)
|
||||
for _, col := range groupByCols {
|
||||
innerSelectCols = append(innerSelectCols,
|
||||
fmt.Sprintf("JSONExtractString(labels, %s) AS %s", innerSB.Var(col), quoteIdentifier(col)),
|
||||
)
|
||||
}
|
||||
|
||||
// Build the argMax(tuple(...), unix_milli) expression for all additional cols
|
||||
if len(additionalCols) > 0 {
|
||||
tupleArgs := make([]string, 0, len(additionalCols))
|
||||
for _, col := range additionalCols {
|
||||
tupleArgs = append(tupleArgs, fmt.Sprintf("JSONExtractString(labels, %s)", innerSB.Var(col)))
|
||||
}
|
||||
innerSelectCols = append(innerSelectCols,
|
||||
fmt.Sprintf("argMax(tuple(%s), unix_milli) AS latest_attrs", strings.Join(tupleArgs, ", ")),
|
||||
)
|
||||
}
|
||||
|
||||
innerSB.Select(innerSelectCols...)
|
||||
innerSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, distributedTableName))
|
||||
innerSB.Where(
|
||||
innerSB.In("metric_name", sqlbuilder.List(metricNames)),
|
||||
innerSB.GE("unix_milli", adjustedStart),
|
||||
innerSB.L("unix_milli", adjustedEnd),
|
||||
fmt.Sprintf("fingerprint IN (%s)", innerSB.Var(fpSB)),
|
||||
)
|
||||
|
||||
// Apply optional filter expression
|
||||
if filter != nil && strings.TrimSpace(filter.Expression) != "" {
|
||||
filterClause, err := m.buildFilterClause(ctx, filter, startMs, endMs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if filterClause != nil {
|
||||
innerSB.AddWhereClause(filterClause)
|
||||
}
|
||||
}
|
||||
|
||||
groupByAliases := make([]string, 0, len(groupByCols))
|
||||
for _, col := range groupByCols {
|
||||
groupByAliases = append(groupByAliases, quoteIdentifier(col))
|
||||
}
|
||||
innerSB.GroupBy(groupByAliases...)
|
||||
|
||||
innerQuery, innerArgs := innerSB.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
// --- Build outer query ---
|
||||
// Outer SELECT columns: groupBy cols directly + tupleElement(latest_attrs, N) for each additionalCol
|
||||
outerSelectCols := make([]string, 0, len(allCols))
|
||||
for _, col := range groupByCols {
|
||||
outerSelectCols = append(outerSelectCols, quoteIdentifier(col))
|
||||
}
|
||||
for i, col := range additionalCols {
|
||||
outerSelectCols = append(outerSelectCols,
|
||||
fmt.Sprintf("tupleElement(latest_attrs, %d) AS %s", i+1, quoteIdentifier(col)),
|
||||
)
|
||||
}
|
||||
|
||||
outerSB := sqlbuilder.NewSelectBuilder()
|
||||
outerSB.Select(outerSelectCols...)
|
||||
outerSB.From(fmt.Sprintf("(%s)", innerQuery))
|
||||
|
||||
outerQuery, _ := outerSB.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
// All ? params are in innerArgs; outer query introduces none of its own.
|
||||
|
||||
rows, err := m.telemetryStore.ClickhouseDB().Query(ctx, outerQuery, innerArgs...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
result := make(map[string]map[string]string)
|
||||
|
||||
for rows.Next() {
|
||||
row := make([]string, len(allCols))
|
||||
scanPtrs := make([]any, len(row))
|
||||
for i := range row {
|
||||
scanPtrs[i] = &row[i]
|
||||
}
|
||||
|
||||
if err := rows.Scan(scanPtrs...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
compositeKey := compositeKeyFromList(row[:len(groupByCols)])
|
||||
|
||||
attrMap := make(map[string]string, len(allCols))
|
||||
for i, col := range allCols {
|
||||
attrMap[col] = row[i]
|
||||
}
|
||||
result[compositeKey] = attrMap
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
283
pkg/modules/inframonitoring/implinframonitoring/helpers_test.go
Normal file
283
pkg/modules/inframonitoring/implinframonitoring/helpers_test.go
Normal file
@@ -0,0 +1,283 @@
|
||||
package implinframonitoring
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
func groupByKey(name string) qbtypes.GroupByKey {
|
||||
return qbtypes.GroupByKey{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: name},
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsKeyInGroupByAttrs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
groupByAttrs []qbtypes.GroupByKey
|
||||
key string
|
||||
expectedFound bool
|
||||
}{
|
||||
{
|
||||
name: "key present in single-element list",
|
||||
groupByAttrs: []qbtypes.GroupByKey{groupByKey("host.name")},
|
||||
key: "host.name",
|
||||
expectedFound: true,
|
||||
},
|
||||
{
|
||||
name: "key present in multi-element list",
|
||||
groupByAttrs: []qbtypes.GroupByKey{
|
||||
groupByKey("host.name"),
|
||||
groupByKey("os.type"),
|
||||
groupByKey("k8s.cluster.name"),
|
||||
},
|
||||
key: "os.type",
|
||||
expectedFound: true,
|
||||
},
|
||||
{
|
||||
name: "key at last position",
|
||||
groupByAttrs: []qbtypes.GroupByKey{
|
||||
groupByKey("host.name"),
|
||||
groupByKey("os.type"),
|
||||
},
|
||||
key: "os.type",
|
||||
expectedFound: true,
|
||||
},
|
||||
{
|
||||
name: "key not in list",
|
||||
groupByAttrs: []qbtypes.GroupByKey{groupByKey("host.name")},
|
||||
key: "os.type",
|
||||
expectedFound: false,
|
||||
},
|
||||
{
|
||||
name: "empty group by list",
|
||||
groupByAttrs: []qbtypes.GroupByKey{},
|
||||
key: "host.name",
|
||||
expectedFound: false,
|
||||
},
|
||||
{
|
||||
name: "nil group by list",
|
||||
groupByAttrs: nil,
|
||||
key: "host.name",
|
||||
expectedFound: false,
|
||||
},
|
||||
{
|
||||
name: "empty key string",
|
||||
groupByAttrs: []qbtypes.GroupByKey{groupByKey("host.name")},
|
||||
key: "",
|
||||
expectedFound: false,
|
||||
},
|
||||
{
|
||||
name: "empty key matches empty-named group by key",
|
||||
groupByAttrs: []qbtypes.GroupByKey{groupByKey("")},
|
||||
key: "",
|
||||
expectedFound: true,
|
||||
},
|
||||
{
|
||||
name: "partial match does not count",
|
||||
groupByAttrs: []qbtypes.GroupByKey{
|
||||
groupByKey("host"),
|
||||
},
|
||||
key: "host.name",
|
||||
expectedFound: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := isKeyInGroupByAttrs(tt.groupByAttrs, tt.key)
|
||||
if got != tt.expectedFound {
|
||||
t.Errorf("isKeyInGroupByAttrs(%v, %q) = %v, want %v",
|
||||
tt.groupByAttrs, tt.key, got, tt.expectedFound)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeFilterExpressions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
queryFilterExpr string
|
||||
reqFilterExpr string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "both non-empty",
|
||||
queryFilterExpr: "cpu > 50",
|
||||
reqFilterExpr: "host.name = 'web-1'",
|
||||
expected: "(cpu > 50) AND (host.name = 'web-1')",
|
||||
},
|
||||
{
|
||||
name: "query empty, req non-empty",
|
||||
queryFilterExpr: "",
|
||||
reqFilterExpr: "host.name = 'web-1'",
|
||||
expected: "host.name = 'web-1'",
|
||||
},
|
||||
{
|
||||
name: "query non-empty, req empty",
|
||||
queryFilterExpr: "cpu > 50",
|
||||
reqFilterExpr: "",
|
||||
expected: "cpu > 50",
|
||||
},
|
||||
{
|
||||
name: "both empty",
|
||||
queryFilterExpr: "",
|
||||
reqFilterExpr: "",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "whitespace-only query treated as empty",
|
||||
queryFilterExpr: " ",
|
||||
reqFilterExpr: "host.name = 'web-1'",
|
||||
expected: "host.name = 'web-1'",
|
||||
},
|
||||
{
|
||||
name: "whitespace-only req treated as empty",
|
||||
queryFilterExpr: "cpu > 50",
|
||||
reqFilterExpr: " ",
|
||||
expected: "cpu > 50",
|
||||
},
|
||||
{
|
||||
name: "both whitespace-only",
|
||||
queryFilterExpr: " ",
|
||||
reqFilterExpr: " ",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "leading/trailing whitespace trimmed before merge",
|
||||
queryFilterExpr: " cpu > 50 ",
|
||||
reqFilterExpr: " mem < 80 ",
|
||||
expected: "(cpu > 50) AND (mem < 80)",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := mergeFilterExpressions(tt.queryFilterExpr, tt.reqFilterExpr)
|
||||
if got != tt.expected {
|
||||
t.Errorf("mergeFilterExpressions(%q, %q) = %q, want %q",
|
||||
tt.queryFilterExpr, tt.reqFilterExpr, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompositeKeyFromList(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
parts []string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "single part",
|
||||
parts: []string{"web-1"},
|
||||
expected: "web-1",
|
||||
},
|
||||
{
|
||||
name: "multiple parts joined with null separator",
|
||||
parts: []string{"web-1", "linux", "us-east"},
|
||||
expected: "web-1\x00linux\x00us-east",
|
||||
},
|
||||
{
|
||||
name: "empty slice returns empty string",
|
||||
parts: []string{},
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "nil slice returns empty string",
|
||||
parts: nil,
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "parts with empty strings",
|
||||
parts: []string{"web-1", "", "us-east"},
|
||||
expected: "web-1\x00\x00us-east",
|
||||
},
|
||||
{
|
||||
name: "all empty strings",
|
||||
parts: []string{"", ""},
|
||||
expected: "\x00",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := compositeKeyFromList(tt.parts)
|
||||
if got != tt.expected {
|
||||
t.Errorf("compositeKeyFromList(%v) = %q, want %q",
|
||||
tt.parts, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompositeKeyFromLabels(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
labels map[string]string
|
||||
groupBy []qbtypes.GroupByKey
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "single group-by key",
|
||||
labels: map[string]string{"host.name": "web-1"},
|
||||
groupBy: []qbtypes.GroupByKey{groupByKey("host.name")},
|
||||
expected: "web-1",
|
||||
},
|
||||
{
|
||||
name: "multiple group-by keys joined with null separator",
|
||||
labels: map[string]string{
|
||||
"host.name": "web-1",
|
||||
"os.type": "linux",
|
||||
},
|
||||
groupBy: []qbtypes.GroupByKey{groupByKey("host.name"), groupByKey("os.type")},
|
||||
expected: "web-1\x00linux",
|
||||
},
|
||||
{
|
||||
name: "missing label yields empty segment",
|
||||
labels: map[string]string{"host.name": "web-1"},
|
||||
groupBy: []qbtypes.GroupByKey{groupByKey("host.name"), groupByKey("os.type")},
|
||||
expected: "web-1\x00",
|
||||
},
|
||||
{
|
||||
name: "empty labels map",
|
||||
labels: map[string]string{},
|
||||
groupBy: []qbtypes.GroupByKey{groupByKey("host.name")},
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "empty group-by slice",
|
||||
labels: map[string]string{"host.name": "web-1"},
|
||||
groupBy: []qbtypes.GroupByKey{},
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "nil labels map",
|
||||
labels: nil,
|
||||
groupBy: []qbtypes.GroupByKey{groupByKey("host.name")},
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "order matches group-by order, not map iteration order",
|
||||
labels: map[string]string{
|
||||
"z": "last",
|
||||
"a": "first",
|
||||
"m": "middle",
|
||||
},
|
||||
groupBy: []qbtypes.GroupByKey{groupByKey("a"), groupByKey("m"), groupByKey("z")},
|
||||
expected: "first\x00middle\x00last",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := compositeKeyFromLabels(tt.labels, tt.groupBy)
|
||||
if got != tt.expected {
|
||||
t.Errorf("compositeKeyFromLabels(%v, %v) = %q, want %q",
|
||||
tt.labels, tt.groupBy, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
354
pkg/modules/inframonitoring/implinframonitoring/hosts.go
Normal file
354
pkg/modules/inframonitoring/implinframonitoring/hosts.go
Normal file
@@ -0,0 +1,354 @@
|
||||
package implinframonitoring
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
|
||||
"github.com/SigNoz/signoz/pkg/types/inframonitoringtypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/huandu/go-sqlbuilder"
|
||||
)
|
||||
|
||||
// getPerGroupHostStatusCounts computes the number of active and inactive hosts per group
|
||||
// for the current page. It queries the timeseries table using the provided filter
|
||||
// expression (which includes user filter + status filter + page groups IN clause).
|
||||
// Uses GLOBAL IN with the active-hosts subquery inside uniqExactIf for active count,
|
||||
// and a simple uniqExactIf for total count. Inactive = total - active (computed in Go).
|
||||
func (m *module) getPerGroupHostStatusCounts(
|
||||
ctx context.Context,
|
||||
req *inframonitoringtypes.PostableHosts,
|
||||
metricNames []string,
|
||||
pageGroups []map[string]string,
|
||||
sinceUnixMilli int64,
|
||||
) (map[string]groupHostStatusCounts, error) {
|
||||
|
||||
// Build the full filter expression from req (user filter + status filter) and page groups.
|
||||
reqFilterExpr := ""
|
||||
if req.Filter != nil {
|
||||
reqFilterExpr = req.Filter.Expression
|
||||
}
|
||||
pageGroupsFilterExpr := buildPageGroupsFilterExpr(pageGroups)
|
||||
filterExpr := mergeFilterExpressions(reqFilterExpr, pageGroupsFilterExpr)
|
||||
|
||||
adjustedStart, adjustedEnd, distributedTimeSeriesTableName, _ := telemetrymetrics.WhichTSTableToUse(
|
||||
uint64(req.Start), uint64(req.End), nil,
|
||||
)
|
||||
|
||||
hostNameExpr := fmt.Sprintf("JSONExtractString(labels, '%s')", hostNameAttrKey)
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
selectCols := make([]string, 0, len(req.GroupBy)+2)
|
||||
for _, key := range req.GroupBy {
|
||||
selectCols = append(selectCols,
|
||||
fmt.Sprintf("JSONExtractString(labels, %s) AS %s", sb.Var(key.Name), quoteIdentifier(key.Name)),
|
||||
)
|
||||
}
|
||||
|
||||
activeHostsSQ := m.getActiveHostsQuery(metricNames, hostNameAttrKey, sinceUnixMilli)
|
||||
selectCols = append(selectCols,
|
||||
fmt.Sprintf("uniqExactIf(%s, %s GLOBAL IN (%s)) AS active_host_count", hostNameExpr, hostNameExpr, sb.Var(activeHostsSQ)),
|
||||
fmt.Sprintf("uniqExactIf(%s, %s != '') AS total_host_count", hostNameExpr, hostNameExpr),
|
||||
)
|
||||
|
||||
// Build a fingerprint subquery to restrict to fingerprints with actual sample
|
||||
// data in the original time range (not the wider timeseries table window).
|
||||
fpSB := m.buildSamplesTblFingerprintSubQuery(metricNames, req.Start, req.End)
|
||||
|
||||
sb.Select(selectCols...)
|
||||
sb.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, distributedTimeSeriesTableName))
|
||||
sb.Where(
|
||||
sb.In("metric_name", sqlbuilder.List(metricNames)),
|
||||
sb.GE("unix_milli", adjustedStart),
|
||||
sb.L("unix_milli", adjustedEnd),
|
||||
fmt.Sprintf("fingerprint IN (%s)", sb.Var(fpSB)),
|
||||
)
|
||||
|
||||
// Apply the combined filter expression (user filter + status filter + page groups IN).
|
||||
if filterExpr != "" {
|
||||
filterClause, err := m.buildFilterClause(ctx, &qbtypes.Filter{Expression: filterExpr}, req.Start, req.End)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if filterClause != nil {
|
||||
sb.AddWhereClause(filterClause)
|
||||
}
|
||||
}
|
||||
|
||||
// GROUP BY
|
||||
groupByAliases := make([]string, 0, len(req.GroupBy))
|
||||
for _, key := range req.GroupBy {
|
||||
groupByAliases = append(groupByAliases, quoteIdentifier(key.Name))
|
||||
}
|
||||
sb.GroupBy(groupByAliases...)
|
||||
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
rows, err := m.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
result := make(map[string]groupHostStatusCounts)
|
||||
for rows.Next() {
|
||||
groupVals := make([]string, len(req.GroupBy))
|
||||
scanPtrs := make([]any, 0, len(req.GroupBy)+2)
|
||||
for i := range groupVals {
|
||||
scanPtrs = append(scanPtrs, &groupVals[i])
|
||||
}
|
||||
|
||||
var activeCount, totalCount uint64
|
||||
scanPtrs = append(scanPtrs, &activeCount, &totalCount)
|
||||
|
||||
if err := rows.Scan(scanPtrs...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
compositeKey := compositeKeyFromList(groupVals)
|
||||
result[compositeKey] = groupHostStatusCounts{
|
||||
Active: int(activeCount),
|
||||
Inactive: int(totalCount - activeCount),
|
||||
}
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// buildHostRecords constructs the final list of HostRecords for a page.
|
||||
// Groups that had no metric data get default values of -1.
|
||||
//
|
||||
// hostCounts is nil when host.name is in the groupBy — in that case, counts are
|
||||
// derived directly from activeHostsMap (1/0 per host). When non-nil (custom groupBy
|
||||
// without host.name), counts are looked up from the map.
|
||||
func buildHostRecords(
|
||||
isHostNameInGroupBy bool,
|
||||
resp *qbtypes.QueryRangeResponse,
|
||||
pageGroups []map[string]string,
|
||||
groupBy []qbtypes.GroupByKey,
|
||||
metadataMap map[string]map[string]string,
|
||||
activeHostsMap map[string]bool,
|
||||
hostCounts map[string]groupHostStatusCounts,
|
||||
) []inframonitoringtypes.HostRecord {
|
||||
metricsMap := parseFullQueryResponse(resp, groupBy)
|
||||
|
||||
records := make([]inframonitoringtypes.HostRecord, 0, len(pageGroups))
|
||||
for _, labels := range pageGroups {
|
||||
compositeKey := compositeKeyFromLabels(labels, groupBy)
|
||||
hostName := labels[hostNameAttrKey]
|
||||
|
||||
activeStatus := inframonitoringtypes.HostStatusNone
|
||||
activeHostCount := 0
|
||||
inactiveHostCount := 0
|
||||
if isHostNameInGroupBy { // derive from activeHostsMap since each row = one host
|
||||
if hostName != "" {
|
||||
if activeHostsMap[hostName] {
|
||||
activeStatus = inframonitoringtypes.HostStatusActive
|
||||
activeHostCount = 1
|
||||
} else {
|
||||
activeStatus = inframonitoringtypes.HostStatusInactive
|
||||
inactiveHostCount = 1
|
||||
}
|
||||
}
|
||||
} else { // derive from hostCounts since custom groupBy without host.name
|
||||
if counts, ok := hostCounts[compositeKey]; ok {
|
||||
activeHostCount = counts.Active
|
||||
inactiveHostCount = counts.Inactive
|
||||
}
|
||||
}
|
||||
|
||||
record := inframonitoringtypes.HostRecord{
|
||||
HostName: hostName,
|
||||
Status: activeStatus,
|
||||
ActiveHostCount: activeHostCount,
|
||||
InactiveHostCount: inactiveHostCount,
|
||||
CPU: -1,
|
||||
Memory: -1,
|
||||
Wait: -1,
|
||||
Load15: -1,
|
||||
DiskUsage: -1,
|
||||
Meta: map[string]any{},
|
||||
}
|
||||
|
||||
if metrics, ok := metricsMap[compositeKey]; ok {
|
||||
if v, exists := metrics["F1"]; exists {
|
||||
record.CPU = v
|
||||
}
|
||||
if v, exists := metrics["F2"]; exists {
|
||||
record.Memory = v
|
||||
}
|
||||
if v, exists := metrics["F3"]; exists {
|
||||
record.Wait = v
|
||||
}
|
||||
if v, exists := metrics["F4"]; exists {
|
||||
record.DiskUsage = v
|
||||
}
|
||||
if v, exists := metrics["G"]; exists {
|
||||
record.Load15 = v
|
||||
}
|
||||
}
|
||||
|
||||
if attrs, ok := metadataMap[compositeKey]; ok {
|
||||
for k, v := range attrs {
|
||||
record.Meta[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
records = append(records, record)
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
// getTopHostGroups runs a ranking query for the ordering metric, sorts the
|
||||
// results, paginates, and backfills from metadataMap when the page extends
|
||||
// past the metric-ranked groups.
|
||||
func (m *module) getTopHostGroups(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
req *inframonitoringtypes.PostableHosts,
|
||||
metadataMap map[string]map[string]string,
|
||||
) ([]map[string]string, error) {
|
||||
orderByKey := req.OrderBy.Key.Name
|
||||
queryNamesForOrderBy := orderByToHostsQueryNames[orderByKey]
|
||||
// The last entry is the formula/query whose value we sort by.
|
||||
rankingQueryName := queryNamesForOrderBy[len(queryNamesForOrderBy)-1]
|
||||
|
||||
topReq := &qbtypes.QueryRangeRequest{
|
||||
Start: uint64(req.Start),
|
||||
End: uint64(req.End),
|
||||
RequestType: qbtypes.RequestTypeScalar,
|
||||
CompositeQuery: qbtypes.CompositeQuery{
|
||||
Queries: make([]qbtypes.QueryEnvelope, 0, len(queryNamesForOrderBy)),
|
||||
},
|
||||
}
|
||||
|
||||
for _, envelope := range m.newListHostsQuery().CompositeQuery.Queries {
|
||||
if !slices.Contains(queryNamesForOrderBy, envelope.GetQueryName()) {
|
||||
continue
|
||||
}
|
||||
copied := envelope
|
||||
if copied.Type == qbtypes.QueryTypeBuilder {
|
||||
existingExpr := ""
|
||||
if f := copied.GetFilter(); f != nil {
|
||||
existingExpr = f.Expression
|
||||
}
|
||||
reqFilterExpr := ""
|
||||
if req.Filter != nil {
|
||||
reqFilterExpr = req.Filter.Expression
|
||||
}
|
||||
merged := mergeFilterExpressions(existingExpr, reqFilterExpr)
|
||||
copied.SetFilter(&qbtypes.Filter{Expression: merged})
|
||||
copied.SetGroupBy(req.GroupBy)
|
||||
}
|
||||
topReq.CompositeQuery.Queries = append(topReq.CompositeQuery.Queries, copied)
|
||||
}
|
||||
|
||||
resp, err := m.querier.QueryRange(ctx, orgID, topReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
allMetricGroups := parseAndSortGroups(resp, rankingQueryName, req.GroupBy, req.OrderBy.Direction)
|
||||
return paginateWithBackfill(allMetricGroups, metadataMap, req.GroupBy, req.Offset, req.Limit), nil
|
||||
}
|
||||
|
||||
// applyHostsActiveStatusFilter MODIFIES req.Filter.Expression to include an IN/NOT IN
|
||||
// clause based on FilterByStatus and the set of active hosts.
|
||||
// Returns true if the caller should short-circuit with an empty result (eg. ACTIVE
|
||||
// requested but no hosts are active).
|
||||
func (m *module) applyHostsActiveStatusFilter(req *inframonitoringtypes.PostableHosts, activeHostsMap map[string]bool) (shouldShortCircuit bool) {
|
||||
if req.Filter == nil || (req.Filter.FilterByStatus != inframonitoringtypes.HostStatusActive && req.Filter.FilterByStatus != inframonitoringtypes.HostStatusInactive) {
|
||||
return false
|
||||
}
|
||||
|
||||
activeHosts := make([]string, 0, len(activeHostsMap))
|
||||
for host := range activeHostsMap {
|
||||
activeHosts = append(activeHosts, fmt.Sprintf("'%s'", host))
|
||||
}
|
||||
|
||||
if len(activeHosts) == 0 {
|
||||
return req.Filter.FilterByStatus == inframonitoringtypes.HostStatusActive
|
||||
}
|
||||
|
||||
op := "IN"
|
||||
if req.Filter.FilterByStatus == inframonitoringtypes.HostStatusInactive {
|
||||
op = "NOT IN"
|
||||
}
|
||||
statusClause := fmt.Sprintf("%s %s (%s)", hostNameAttrKey, op, strings.Join(activeHosts, ", "))
|
||||
req.Filter.Expression = mergeFilterExpressions(req.Filter.Expression, statusClause)
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *module) getHostsTableMetadata(ctx context.Context, req *inframonitoringtypes.PostableHosts) (map[string]map[string]string, error) {
|
||||
var nonGroupByAttrs []string
|
||||
for _, key := range hostAttrKeysForMetadata {
|
||||
if !isKeyInGroupByAttrs(req.GroupBy, key) {
|
||||
nonGroupByAttrs = append(nonGroupByAttrs, key)
|
||||
}
|
||||
}
|
||||
var filter *qbtypes.Filter
|
||||
if req.Filter != nil {
|
||||
filter = &req.Filter.Filter
|
||||
}
|
||||
metadataMap, err := m.getMetadata(ctx, hostsTableMetricNamesList, req.GroupBy, nonGroupByAttrs, filter, req.Start, req.End)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return metadataMap, nil
|
||||
}
|
||||
|
||||
// getActiveHostsQuery builds a SelectBuilder that returns distinct host names
|
||||
// with metrics reported in the last 10 minutes. The builder is not executed —
|
||||
// callers can either execute it (getActiveHosts) or embed it as a subquery
|
||||
// (getPerGroupActiveInactiveHostCounts).
|
||||
func (m *module) getActiveHostsQuery(metricNames []string, hostNameAttr string, sinceUnixMilli int64) *sqlbuilder.SelectBuilder {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Distinct()
|
||||
sb.Select("attr_string_value")
|
||||
sb.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.AttributesMetadataTableName))
|
||||
sb.Where(
|
||||
sb.In("metric_name", sqlbuilder.List(metricNames)),
|
||||
sb.E("attr_name", hostNameAttr),
|
||||
sb.NE("attr_string_value", ""),
|
||||
sb.GE("last_reported_unix_milli", sinceUnixMilli),
|
||||
)
|
||||
|
||||
return sb
|
||||
}
|
||||
|
||||
// getActiveHosts returns a set of host names that have reported metrics recently.
|
||||
// It queries distributed_metadata for hosts where last_reported_unix_milli >= sinceUnixMilli.
|
||||
func (m *module) getActiveHosts(ctx context.Context, metricNames []string, hostNameAttr string, sinceUnixMilli int64) (map[string]bool, error) {
|
||||
sb := m.getActiveHostsQuery(metricNames, hostNameAttr, sinceUnixMilli)
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
rows, err := m.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
activeHosts := make(map[string]bool)
|
||||
for rows.Next() {
|
||||
var hostName string
|
||||
if err := rows.Scan(&hostName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if hostName != "" {
|
||||
activeHosts[hostName] = true
|
||||
}
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return activeHosts, nil
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
package implinframonitoring
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/types/inframonitoringtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/metrictypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
const (
|
||||
hostNameAttrKey = "host.name"
|
||||
)
|
||||
|
||||
// Helper group-by key used across all queries.
|
||||
var hostNameGroupByKey = qbtypes.GroupByKey{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: hostNameAttrKey,
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
}
|
||||
|
||||
var hostsTableMetricNamesList = []string{
|
||||
"system.cpu.time",
|
||||
"system.memory.usage",
|
||||
"system.cpu.load_average.15m",
|
||||
"system.filesystem.usage",
|
||||
}
|
||||
|
||||
var hostAttrKeysForMetadata = []string{
|
||||
"os.type",
|
||||
}
|
||||
|
||||
// orderByToHostsQueryNames maps the orderBy column to the query/formula names
|
||||
// from HostsTableListQuery used for ranking host groups.
|
||||
var orderByToHostsQueryNames = map[string][]string{
|
||||
inframonitoringtypes.HostsOrderByCPU: {"A", "B", "F1"},
|
||||
inframonitoringtypes.HostsOrderByMemory: {"C", "D", "F2"},
|
||||
inframonitoringtypes.HostsOrderByWait: {"E", "F", "F3"},
|
||||
inframonitoringtypes.HostsOrderByDiskUsage: {"H", "I", "F4"},
|
||||
inframonitoringtypes.HostsOrderByLoad15: {"G"},
|
||||
}
|
||||
|
||||
// newListHostsQuery constructs the base QueryRangeRequest with all the queries for the hosts table.
|
||||
// This is kept in this file because the queries themselves do not change based on the request parameters
|
||||
// only the filters, group bys, and order bys change, which are applied in buildFullQueryRequest.
|
||||
func (m *module) newListHostsQuery() *qbtypes.QueryRangeRequest {
|
||||
return &qbtypes.QueryRangeRequest{
|
||||
RequestType: qbtypes.RequestTypeScalar,
|
||||
CompositeQuery: qbtypes.CompositeQuery{
|
||||
Queries: []qbtypes.QueryEnvelope{
|
||||
// Query A: CPU usage logic (non-idle)
|
||||
{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Name: "A",
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Aggregations: []qbtypes.MetricAggregation{
|
||||
{
|
||||
MetricName: "system.cpu.time",
|
||||
TimeAggregation: metrictypes.TimeAggregationRate,
|
||||
SpaceAggregation: metrictypes.SpaceAggregationSum,
|
||||
ReduceTo: qbtypes.ReduceToAvg,
|
||||
},
|
||||
},
|
||||
Filter: &qbtypes.Filter{
|
||||
Expression: "state != 'idle'",
|
||||
},
|
||||
GroupBy: []qbtypes.GroupByKey{hostNameGroupByKey},
|
||||
Disabled: true,
|
||||
},
|
||||
},
|
||||
// Query B: CPU usage (all states)
|
||||
{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Name: "B",
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Aggregations: []qbtypes.MetricAggregation{
|
||||
{
|
||||
MetricName: "system.cpu.time",
|
||||
TimeAggregation: metrictypes.TimeAggregationRate,
|
||||
SpaceAggregation: metrictypes.SpaceAggregationSum,
|
||||
ReduceTo: qbtypes.ReduceToAvg,
|
||||
},
|
||||
},
|
||||
GroupBy: []qbtypes.GroupByKey{hostNameGroupByKey},
|
||||
Disabled: true,
|
||||
},
|
||||
},
|
||||
// Formula F1: CPU Usage (%)
|
||||
{
|
||||
Type: qbtypes.QueryTypeFormula,
|
||||
Spec: qbtypes.QueryBuilderFormula{
|
||||
Name: "F1",
|
||||
Expression: "A/B",
|
||||
Legend: "CPU Usage (%)",
|
||||
Disabled: false,
|
||||
},
|
||||
},
|
||||
// Query C: Memory usage (state = used)
|
||||
{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Name: "C",
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Aggregations: []qbtypes.MetricAggregation{
|
||||
{
|
||||
MetricName: "system.memory.usage",
|
||||
TimeAggregation: metrictypes.TimeAggregationAvg,
|
||||
SpaceAggregation: metrictypes.SpaceAggregationSum,
|
||||
ReduceTo: qbtypes.ReduceToAvg,
|
||||
},
|
||||
},
|
||||
Filter: &qbtypes.Filter{
|
||||
Expression: "state = 'used'",
|
||||
},
|
||||
GroupBy: []qbtypes.GroupByKey{hostNameGroupByKey},
|
||||
Disabled: true,
|
||||
},
|
||||
},
|
||||
// Query D: Memory usage (all states)
|
||||
{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Name: "D",
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Aggregations: []qbtypes.MetricAggregation{
|
||||
{
|
||||
MetricName: "system.memory.usage",
|
||||
TimeAggregation: metrictypes.TimeAggregationAvg,
|
||||
SpaceAggregation: metrictypes.SpaceAggregationSum,
|
||||
ReduceTo: qbtypes.ReduceToAvg,
|
||||
},
|
||||
},
|
||||
GroupBy: []qbtypes.GroupByKey{hostNameGroupByKey},
|
||||
Disabled: true,
|
||||
},
|
||||
},
|
||||
// Formula F2: Memory Usage (%)
|
||||
{
|
||||
Type: qbtypes.QueryTypeFormula,
|
||||
Spec: qbtypes.QueryBuilderFormula{
|
||||
Name: "F2",
|
||||
Expression: "C/D",
|
||||
Legend: "Memory Usage (%)",
|
||||
Disabled: false,
|
||||
},
|
||||
},
|
||||
// Query E: CPU Wait time (state = wait)
|
||||
{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Name: "E",
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Aggregations: []qbtypes.MetricAggregation{
|
||||
{
|
||||
MetricName: "system.cpu.time",
|
||||
TimeAggregation: metrictypes.TimeAggregationRate,
|
||||
SpaceAggregation: metrictypes.SpaceAggregationSum,
|
||||
ReduceTo: qbtypes.ReduceToAvg,
|
||||
},
|
||||
},
|
||||
Filter: &qbtypes.Filter{
|
||||
Expression: "state = 'wait'",
|
||||
},
|
||||
GroupBy: []qbtypes.GroupByKey{hostNameGroupByKey},
|
||||
Disabled: true,
|
||||
},
|
||||
},
|
||||
// Query F: CPU time (all states)
|
||||
{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Name: "F",
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Aggregations: []qbtypes.MetricAggregation{
|
||||
{
|
||||
MetricName: "system.cpu.time",
|
||||
TimeAggregation: metrictypes.TimeAggregationRate,
|
||||
SpaceAggregation: metrictypes.SpaceAggregationSum,
|
||||
ReduceTo: qbtypes.ReduceToAvg,
|
||||
},
|
||||
},
|
||||
GroupBy: []qbtypes.GroupByKey{hostNameGroupByKey},
|
||||
Disabled: true,
|
||||
},
|
||||
},
|
||||
// Formula F3: CPU Wait Time (%)
|
||||
{
|
||||
Type: qbtypes.QueryTypeFormula,
|
||||
Spec: qbtypes.QueryBuilderFormula{
|
||||
Name: "F3",
|
||||
Expression: "E/F",
|
||||
Legend: "CPU Wait Time (%)",
|
||||
Disabled: false,
|
||||
},
|
||||
},
|
||||
// Query G: Load15
|
||||
{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Name: "G",
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Legend: "CPU Load Average (15m)",
|
||||
Aggregations: []qbtypes.MetricAggregation{
|
||||
{
|
||||
MetricName: "system.cpu.load_average.15m",
|
||||
TimeAggregation: metrictypes.TimeAggregationAvg,
|
||||
SpaceAggregation: metrictypes.SpaceAggregationSum,
|
||||
ReduceTo: qbtypes.ReduceToAvg,
|
||||
},
|
||||
},
|
||||
GroupBy: []qbtypes.GroupByKey{hostNameGroupByKey},
|
||||
Disabled: false,
|
||||
},
|
||||
},
|
||||
// Query H: Filesystem Usage (state = used)
|
||||
{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Name: "H",
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Aggregations: []qbtypes.MetricAggregation{
|
||||
{
|
||||
MetricName: "system.filesystem.usage",
|
||||
TimeAggregation: metrictypes.TimeAggregationAvg,
|
||||
SpaceAggregation: metrictypes.SpaceAggregationSum,
|
||||
ReduceTo: qbtypes.ReduceToAvg,
|
||||
},
|
||||
},
|
||||
Filter: &qbtypes.Filter{
|
||||
Expression: "state = 'used'",
|
||||
},
|
||||
GroupBy: []qbtypes.GroupByKey{hostNameGroupByKey},
|
||||
Disabled: true,
|
||||
},
|
||||
},
|
||||
// Query I: Filesystem Usage (all states)
|
||||
{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Name: "I",
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Aggregations: []qbtypes.MetricAggregation{
|
||||
{
|
||||
MetricName: "system.filesystem.usage",
|
||||
TimeAggregation: metrictypes.TimeAggregationAvg,
|
||||
SpaceAggregation: metrictypes.SpaceAggregationSum,
|
||||
ReduceTo: qbtypes.ReduceToAvg,
|
||||
},
|
||||
},
|
||||
GroupBy: []qbtypes.GroupByKey{hostNameGroupByKey},
|
||||
Disabled: true,
|
||||
},
|
||||
},
|
||||
// Formula F4: Disk Usage (%)
|
||||
{
|
||||
Type: qbtypes.QueryTypeFormula,
|
||||
Spec: qbtypes.QueryBuilderFormula{
|
||||
Name: "F4",
|
||||
Expression: "H/I",
|
||||
Legend: "Disk Usage (%)",
|
||||
Disabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package implinframonitoring
|
||||
|
||||
// The types in this file are only used within the implinframonitoring package, and are not exposed outside.
|
||||
// They are primarily used for internal processing and structuring of data within the module's implementation.
|
||||
|
||||
type rankedGroup struct {
|
||||
labels map[string]string
|
||||
value float64
|
||||
compositeKey string
|
||||
}
|
||||
|
||||
// groupHostStatusCounts holds per-group active and inactive host counts.
|
||||
type groupHostStatusCounts struct {
|
||||
Active int
|
||||
Inactive int
|
||||
}
|
||||
161
pkg/modules/inframonitoring/implinframonitoring/module.go
Normal file
161
pkg/modules/inframonitoring/implinframonitoring/module.go
Normal file
@@ -0,0 +1,161 @@
|
||||
package implinframonitoring
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/modules/inframonitoring"
|
||||
"github.com/SigNoz/signoz/pkg/querier"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/types/inframonitoringtypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type module struct {
|
||||
telemetryStore telemetrystore.TelemetryStore
|
||||
telemetryMetadataStore telemetrytypes.MetadataStore
|
||||
querier querier.Querier
|
||||
fieldMapper qbtypes.FieldMapper
|
||||
condBuilder qbtypes.ConditionBuilder
|
||||
logger *slog.Logger
|
||||
config inframonitoring.Config
|
||||
}
|
||||
|
||||
// NewModule constructs the inframonitoring module with the provided dependencies.
|
||||
func NewModule(
|
||||
telemetryStore telemetrystore.TelemetryStore,
|
||||
telemetryMetadataStore telemetrytypes.MetadataStore,
|
||||
querier querier.Querier,
|
||||
providerSettings factory.ProviderSettings,
|
||||
cfg inframonitoring.Config,
|
||||
) inframonitoring.Module {
|
||||
fieldMapper := telemetrymetrics.NewFieldMapper()
|
||||
condBuilder := telemetrymetrics.NewConditionBuilder(fieldMapper)
|
||||
return &module{
|
||||
telemetryStore: telemetryStore,
|
||||
telemetryMetadataStore: telemetryMetadataStore,
|
||||
querier: querier,
|
||||
fieldMapper: fieldMapper,
|
||||
condBuilder: condBuilder,
|
||||
logger: providerSettings.Logger,
|
||||
config: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *module) ListHosts(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableHosts) (*inframonitoringtypes.Hosts, error) {
|
||||
if err := req.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp := &inframonitoringtypes.Hosts{}
|
||||
|
||||
// default to cpu order by
|
||||
if req.OrderBy == nil {
|
||||
req.OrderBy = &qbtypes.OrderBy{
|
||||
Key: qbtypes.OrderByKey{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "cpu",
|
||||
},
|
||||
},
|
||||
Direction: qbtypes.OrderDirectionDesc,
|
||||
}
|
||||
}
|
||||
|
||||
// default to host name group by
|
||||
if len(req.GroupBy) == 0 {
|
||||
req.GroupBy = []qbtypes.GroupByKey{hostNameGroupByKey}
|
||||
resp.Type = inframonitoringtypes.ResponseTypeList
|
||||
} else {
|
||||
resp.Type = inframonitoringtypes.ResponseTypeGroupedList
|
||||
}
|
||||
|
||||
// 1. Check which required metrics exist and get earliest retention time.
|
||||
// If any required metric is missing, return early with the list of missing metrics.
|
||||
// 2. If metrics exist but req.End is before the earliest reported time, return early with endTimeBeforeRetention=true.
|
||||
missingMetrics, minFirstReportedUnixMilli, err := m.getMetricsExistenceAndEarliestTime(ctx, hostsTableMetricNamesList)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(missingMetrics) > 0 {
|
||||
resp.RequiredMetricsCheck = inframonitoringtypes.RequiredMetricsCheck{MissingMetrics: missingMetrics}
|
||||
resp.Records = []inframonitoringtypes.HostRecord{}
|
||||
resp.Total = 0
|
||||
return resp, nil
|
||||
}
|
||||
if req.End < int64(minFirstReportedUnixMilli) {
|
||||
resp.EndTimeBeforeRetention = true
|
||||
resp.Records = []inframonitoringtypes.HostRecord{}
|
||||
resp.Total = 0
|
||||
return resp, nil
|
||||
}
|
||||
resp.RequiredMetricsCheck = inframonitoringtypes.RequiredMetricsCheck{MissingMetrics: []string{}}
|
||||
|
||||
// TOD(nikhilmantri0902): replace this separate ClickHouse query with a sub-query inside the main query builder query
|
||||
// once QB supports sub-queries.
|
||||
// Determine active hosts: those with metrics reported in the last 10 minutes.
|
||||
// Compute the cutoff once so every downstream query/subquery agrees on what "active" means.
|
||||
sinceUnixMilli := time.Now().Add(-10 * time.Minute).UTC().UnixMilli()
|
||||
activeHostsMap, err := m.getActiveHosts(ctx, hostsTableMetricNamesList, hostNameAttrKey, sinceUnixMilli)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// this check below modifies req.Filter by adding `AND active hosts filter` if req.FilterByStatus is set.
|
||||
if m.applyHostsActiveStatusFilter(req, activeHostsMap) {
|
||||
resp.Records = []inframonitoringtypes.HostRecord{}
|
||||
resp.Total = 0
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
metadataMap, err := m.getHostsTableMetadata(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp.Total = len(metadataMap)
|
||||
|
||||
pageGroups, err := m.getTopHostGroups(ctx, orgID, req, metadataMap)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(pageGroups) == 0 {
|
||||
resp.Records = []inframonitoringtypes.HostRecord{}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
hostsFilterExpr := ""
|
||||
if req.Filter != nil {
|
||||
hostsFilterExpr = req.Filter.Expression
|
||||
}
|
||||
|
||||
fullQueryReq := buildFullQueryRequest(req.Start, req.End, hostsFilterExpr, req.GroupBy, pageGroups, m.newListHostsQuery())
|
||||
queryResp, err := m.querier.QueryRange(ctx, orgID, fullQueryReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Compute per-group active/inactive host counts.
|
||||
// When host.name is in groupBy, each row = one host, so counts are derived
|
||||
// directly from activeHostsMap in buildHostRecords (no extra query needed).
|
||||
// When host.name is not in groupBy, we need to run an additional query to get the counts per group for the current page,
|
||||
// using the same filter expression as the main query (including user filters + page groups IN clause).
|
||||
hostCounts := make(map[string]groupHostStatusCounts)
|
||||
isHostNameInGroupBy := isKeyInGroupByAttrs(req.GroupBy, hostNameAttrKey)
|
||||
if !isHostNameInGroupBy {
|
||||
hostCounts, err = m.getPerGroupHostStatusCounts(ctx, req, hostsTableMetricNamesList, pageGroups, sinceUnixMilli)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
resp.Records = buildHostRecords(isHostNameInGroupBy, queryResp, pageGroups, req.GroupBy, metadataMap, activeHostsMap, hostCounts)
|
||||
resp.Warning = queryResp.Warning
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
17
pkg/modules/inframonitoring/inframonitoring.go
Normal file
17
pkg/modules/inframonitoring/inframonitoring.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package inframonitoring
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/inframonitoringtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type Handler interface {
|
||||
ListHosts(http.ResponseWriter, *http.Request)
|
||||
}
|
||||
|
||||
type Module interface {
|
||||
ListHosts(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableHosts) (*inframonitoringtypes.Hosts, error)
|
||||
}
|
||||
30
pkg/modules/serviceaccount/implserviceaccount/getter.go
Normal file
30
pkg/modules/serviceaccount/implserviceaccount/getter.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package implserviceaccount
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/serviceaccounttypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type getter struct {
|
||||
store serviceaccounttypes.Store
|
||||
}
|
||||
|
||||
func NewGetter(store serviceaccounttypes.Store) serviceaccount.Getter {
|
||||
return &getter{store: store}
|
||||
}
|
||||
|
||||
func (getter *getter) OnBeforeRoleDelete(ctx context.Context, orgID valuer.UUID, roleID valuer.UUID) error {
|
||||
serviceAccounts, err := getter.store.GetServiceAccountsByOrgIDAndRoleID(ctx, orgID, roleID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(serviceAccounts) > 0 {
|
||||
return errors.New(errors.TypeInvalidInput, authtypes.ErrCodeRoleHasServiceAccountAssignees, "role has active service account assignments, remove them before deleting")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -123,6 +123,25 @@ func (store *store) GetByIDAndStatus(ctx context.Context, id valuer.UUID, status
|
||||
return storable, nil
|
||||
}
|
||||
|
||||
func (store *store) GetServiceAccountsByOrgIDAndRoleID(ctx context.Context, orgID valuer.UUID, roleID valuer.UUID) ([]*serviceaccounttypes.ServiceAccount, error) {
|
||||
serviceAccounts := make([]*serviceaccounttypes.ServiceAccount, 0)
|
||||
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(&serviceAccounts).
|
||||
Join(`JOIN service_account_role ON service_account_role.service_account_id = service_account.id`).
|
||||
Where(`service_account.org_id = ?`, orgID).
|
||||
Where("service_account_role.role_id = ?", roleID).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return serviceAccounts, nil
|
||||
}
|
||||
|
||||
func (store *store) CountByOrgID(ctx context.Context, orgID valuer.UUID) (int64, error) {
|
||||
storable := new(serviceaccounttypes.ServiceAccount)
|
||||
|
||||
|
||||
@@ -11,6 +11,11 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type Getter interface {
|
||||
// OnBeforeRoleDelete checks if any service accounts are assigned to the role and rejects deletion if so.
|
||||
OnBeforeRoleDelete(ctx context.Context, orgID valuer.UUID, roleID valuer.UUID) error
|
||||
}
|
||||
|
||||
type Module interface {
|
||||
// Creates a new service account for an organization.
|
||||
Create(context.Context, valuer.UUID, *serviceaccounttypes.ServiceAccount) error
|
||||
|
||||
49
pkg/modules/tracedetail/config.go
Normal file
49
pkg/modules/tracedetail/config.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package tracedetail
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Waterfall WaterfallConfig `mapstructure:"waterfall"`
|
||||
}
|
||||
|
||||
type WaterfallConfig struct {
|
||||
// SpanPageSize is the window size of spans returned per request.
|
||||
SpanPageSize float64 `mapstructure:"span_page_size"`
|
||||
// MaxDepthToAutoExpand is the depth of auto-expanded descendants below selectedSpanID.
|
||||
MaxDepthToAutoExpand int `mapstructure:"max_depth_to_auto_expand"`
|
||||
// MaxLimitToSelectAllSpans is the threshold below which all spans are returned without windowing.
|
||||
MaxLimitToSelectAllSpans uint `mapstructure:"max_limit_to_select_all_spans"`
|
||||
}
|
||||
|
||||
func NewConfigFactory() factory.ConfigFactory {
|
||||
return factory.NewConfigFactory(factory.MustNewName("tracedetail"), newConfig)
|
||||
}
|
||||
|
||||
func newConfig() factory.Config {
|
||||
return Config{
|
||||
Waterfall: WaterfallConfig{
|
||||
SpanPageSize: 500,
|
||||
MaxDepthToAutoExpand: 5,
|
||||
MaxLimitToSelectAllSpans: 10_000,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (c Config) Validate() error {
|
||||
if c.Waterfall.SpanPageSize <= 0 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"tracedetail.waterfall.span_limit_per_request must be positive, got %v", c.Waterfall.SpanPageSize)
|
||||
}
|
||||
if c.Waterfall.MaxDepthToAutoExpand < 0 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"tracedetail.waterfall.max_depth_for_selected_children cannot be negative, got %d", c.Waterfall.MaxDepthToAutoExpand)
|
||||
}
|
||||
if c.Waterfall.MaxLimitToSelectAllSpans == 0 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"tracedetail.waterfall.max_limit_to_select_all_spans must be positive")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
35
pkg/modules/tracedetail/impltracedetail/handler.go
Normal file
35
pkg/modules/tracedetail/impltracedetail/handler.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package impltracedetail
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/http/binding"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tracedetail"
|
||||
"github.com/SigNoz/signoz/pkg/types/tracedetailtypes"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
type handler struct {
|
||||
module tracedetail.Module
|
||||
}
|
||||
|
||||
func NewHandler(module tracedetail.Module) tracedetail.Handler {
|
||||
return &handler{module: module}
|
||||
}
|
||||
|
||||
func (h *handler) GetWaterfall(rw http.ResponseWriter, r *http.Request) {
|
||||
req := new(tracedetailtypes.PostableWaterfall)
|
||||
if err := binding.JSON.BindBody(r.Body, req); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.module.GetWaterfall(r.Context(), mux.Vars(r)["traceID"], req)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, result)
|
||||
}
|
||||
61
pkg/modules/tracedetail/impltracedetail/module.go
Normal file
61
pkg/modules/tracedetail/impltracedetail/module.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package impltracedetail
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tracedetail"
|
||||
"github.com/SigNoz/signoz/pkg/types/tracedetailtypes"
|
||||
)
|
||||
|
||||
type module struct {
|
||||
store tracedetailtypes.TraceStore
|
||||
settings factory.ScopedProviderSettings
|
||||
config tracedetail.Config
|
||||
}
|
||||
|
||||
func NewModule(traceStore tracedetailtypes.TraceStore, providerSettings factory.ProviderSettings, cfg tracedetail.Config) *module {
|
||||
scopedProviderSettings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/modules/tracedetail/impltracedetail")
|
||||
return &module{
|
||||
config: cfg,
|
||||
store: traceStore,
|
||||
settings: scopedProviderSettings,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *module) GetWaterfall(ctx context.Context, traceID string, req *tracedetailtypes.PostableWaterfall) (*tracedetailtypes.GettableWaterfallTrace, error) {
|
||||
waterfallTrace, err := m.getTraceData(ctx, traceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
selectedSpans, uncollapsedSpans, selectedAllSpans := waterfallTrace.GetWaterfallSpans(
|
||||
req.UncollapsedSpans,
|
||||
req.SelectedSpanID,
|
||||
min(req.Limit, m.config.Waterfall.MaxLimitToSelectAllSpans),
|
||||
m.config.Waterfall.SpanPageSize,
|
||||
m.config.Waterfall.MaxDepthToAutoExpand,
|
||||
)
|
||||
|
||||
return tracedetailtypes.NewGettableWaterfallTrace(waterfallTrace, selectedSpans, uncollapsedSpans, selectedAllSpans), nil
|
||||
}
|
||||
|
||||
// getTraceData returns the waterfall cache for the given traceID with fallback on DB.
|
||||
func (m *module) getTraceData(ctx context.Context, traceID string) (*tracedetailtypes.WaterfallTrace, error) {
|
||||
summary, err := m.store.GetTraceSummary(ctx, traceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
spanItems, err := m.store.GetTraceSpans(ctx, traceID, summary)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(spanItems) == 0 {
|
||||
return nil, tracedetailtypes.ErrTraceNotFound
|
||||
}
|
||||
|
||||
traceData := tracedetailtypes.NewWaterfallTraceFromSpans(spanItems)
|
||||
return traceData, nil
|
||||
}
|
||||
71
pkg/modules/tracedetail/impltracedetail/store.go
Normal file
71
pkg/modules/tracedetail/impltracedetail/store.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package impltracedetail
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
sqlbuilder "github.com/huandu/go-sqlbuilder"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/types/tracedetailtypes"
|
||||
)
|
||||
|
||||
type traceStore struct {
|
||||
telemetryStore telemetrystore.TelemetryStore
|
||||
}
|
||||
|
||||
func NewTraceStore(ts telemetrystore.TelemetryStore) *traceStore {
|
||||
return &traceStore{telemetryStore: ts}
|
||||
}
|
||||
|
||||
func (s *traceStore) GetTraceSummary(ctx context.Context, traceID string) (*tracedetailtypes.TraceSummary, error) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("trace_id", "min(start) AS start", "max(end) AS end", "sum(num_spans) AS num_spans")
|
||||
sb.From(fmt.Sprintf("%s.%s", tracedetailtypes.TraceDB, tracedetailtypes.TraceSummaryTable))
|
||||
sb.Where(sb.E("trace_id", traceID))
|
||||
sb.GroupBy("trace_id")
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
var summary tracedetailtypes.TraceSummary
|
||||
err := s.telemetryStore.ClickhouseDB().QueryRow(ctx, query, args...).Scan(
|
||||
&summary.TraceID, &summary.Start, &summary.End, &summary.NumSpans,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, tracedetailtypes.ErrTraceNotFound
|
||||
}
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error querying trace summary")
|
||||
}
|
||||
return &summary, nil
|
||||
}
|
||||
|
||||
func (s *traceStore) GetTraceSpans(ctx context.Context, traceID string, summary *tracedetailtypes.TraceSummary) ([]tracedetailtypes.StorableSpan, error) {
|
||||
// DISTINCT ON (span_id) is ClickHouse-specific syntax not supported by sqlbuilder
|
||||
query := fmt.Sprintf(`
|
||||
SELECT DISTINCT ON (span_id)
|
||||
timestamp, duration_nano, span_id, trace_id, has_error, kind,
|
||||
resource_string_service$$name, name, links as references,
|
||||
attributes_string, attributes_number, attributes_bool, resources_string,
|
||||
events, status_message, status_code_string, kind_string, parent_span_id,
|
||||
flags, is_remote, trace_state, status_code,
|
||||
db_name, db_operation, http_method, http_url, http_host,
|
||||
external_http_method, external_http_url, response_status_code
|
||||
FROM %s.%s
|
||||
WHERE trace_id=? AND ts_bucket_start>=? AND ts_bucket_start<=?
|
||||
ORDER BY timestamp ASC, name ASC`,
|
||||
tracedetailtypes.TraceDB, tracedetailtypes.TraceTable,
|
||||
)
|
||||
var spanItems []tracedetailtypes.StorableSpan
|
||||
err := s.telemetryStore.ClickhouseDB().Select(
|
||||
ctx, &spanItems, query,
|
||||
traceID,
|
||||
summary.Start.Unix()-1800,
|
||||
summary.End.Unix(),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error querying trace spans")
|
||||
}
|
||||
return spanItems, nil
|
||||
}
|
||||
574
pkg/modules/tracedetail/impltracedetail/waterfall_test.go
Normal file
574
pkg/modules/tracedetail/impltracedetail/waterfall_test.go
Normal file
@@ -0,0 +1,574 @@
|
||||
// Package impltracedetail tests — waterfall
|
||||
//
|
||||
// # Background
|
||||
//
|
||||
// The waterfall view renders a trace as a scrollable list of spans in
|
||||
// pre-order (parent before children, siblings left-to-right). Because a trace
|
||||
// can have thousands of spans, only a window of ~500 is returned per request.
|
||||
// The window is centred on the selected span.
|
||||
//
|
||||
// # Key concepts
|
||||
//
|
||||
// uncollapsedSpans
|
||||
//
|
||||
// The set of span IDs the user has manually expanded in the UI.
|
||||
// Only the direct children of an uncollapsed span are included in the
|
||||
// output; grandchildren stay hidden until their parent is also uncollapsed.
|
||||
// When multiple spans are uncollapsed their children are all visible at once.
|
||||
//
|
||||
// selectedSpanID
|
||||
//
|
||||
// The span currently focused — set when the user clicks a span in the
|
||||
// waterfall or selects one from the flamegraph. The output window is always
|
||||
// centred on this span. The path from the trace root down to the selected
|
||||
// span is automatically uncollapsed so ancestors are visible even if they are
|
||||
// not in uncollapsedSpans.
|
||||
//
|
||||
//
|
||||
// traceRoots
|
||||
//
|
||||
// Root spans of the trace — spans with no parent in the current dataset.
|
||||
// Normally one, but multiple roots are common when upstream services are
|
||||
// not instrumented or their spans were not sampled/exported.
|
||||
|
||||
package impltracedetail
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/tracedetailtypes"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func mkSpan(id, service string, children ...*tracedetailtypes.WaterfallSpan) *tracedetailtypes.WaterfallSpan {
|
||||
return &tracedetailtypes.WaterfallSpan{
|
||||
SpanID: id,
|
||||
ServiceName: service,
|
||||
Name: id + "-op",
|
||||
Children: children,
|
||||
}
|
||||
}
|
||||
|
||||
func spanIDs(spans []*tracedetailtypes.WaterfallSpan) []string {
|
||||
ids := make([]string, len(spans))
|
||||
for i, s := range spans {
|
||||
ids[i] = s.SpanID
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func buildSpanMap(roots ...*tracedetailtypes.WaterfallSpan) map[string]*tracedetailtypes.WaterfallSpan {
|
||||
m := map[string]*tracedetailtypes.WaterfallSpan{}
|
||||
var walk func(*tracedetailtypes.WaterfallSpan)
|
||||
walk = func(s *tracedetailtypes.WaterfallSpan) {
|
||||
m[s.SpanID] = s
|
||||
for _, c := range s.Children {
|
||||
walk(c)
|
||||
}
|
||||
}
|
||||
for _, r := range roots {
|
||||
r.SortChildren()
|
||||
r.GetSubtreeNodeCount()
|
||||
walk(r)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// makeChain builds a linear trace: span0 → span1 → … → span(n-1).
|
||||
func makeChain(n int) (*tracedetailtypes.WaterfallSpan, map[string]*tracedetailtypes.WaterfallSpan, []string) {
|
||||
spans := make([]*tracedetailtypes.WaterfallSpan, n)
|
||||
for i := n - 1; i >= 0; i-- {
|
||||
if i == n-1 {
|
||||
spans[i] = mkSpan(fmt.Sprintf("span%d", i), "svc")
|
||||
} else {
|
||||
spans[i] = mkSpan(fmt.Sprintf("span%d", i), "svc", spans[i+1])
|
||||
}
|
||||
}
|
||||
uncollapsed := make([]string, n)
|
||||
for i := range spans {
|
||||
uncollapsed[i] = fmt.Sprintf("span%d", i)
|
||||
}
|
||||
return spans[0], buildSpanMap(spans[0]), uncollapsed
|
||||
}
|
||||
|
||||
func getWaterfallTrace(roots []*tracedetailtypes.WaterfallSpan, spanMap map[string]*tracedetailtypes.WaterfallSpan) *tracedetailtypes.WaterfallTrace {
|
||||
return tracedetailtypes.NewWaterfallTrace(0, 0, uint64(len(spanMap)), 0, spanMap, nil, roots, false)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// GetSelectedSpans — span ordering and visibility
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestGetSelectedSpans_SpanOrdering(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
buildRoots func() ([]*tracedetailtypes.WaterfallSpan, map[string]*tracedetailtypes.WaterfallSpan)
|
||||
uncollapsedSpans []string
|
||||
selectedSpanID string
|
||||
wantSpanIDs []string
|
||||
}{
|
||||
{
|
||||
// Pre-order traversal is preserved: parent before children, siblings left-to-right.
|
||||
name: "pre_order_traversal",
|
||||
buildRoots: func() ([]*tracedetailtypes.WaterfallSpan, map[string]*tracedetailtypes.WaterfallSpan) {
|
||||
root := mkSpan("root", "svc",
|
||||
mkSpan("child1", "svc", mkSpan("grandchild", "svc")),
|
||||
mkSpan("child2", "svc"),
|
||||
)
|
||||
return []*tracedetailtypes.WaterfallSpan{root}, buildSpanMap(root)
|
||||
},
|
||||
uncollapsedSpans: []string{"root", "child1"},
|
||||
selectedSpanID: "root",
|
||||
wantSpanIDs: []string{"root", "child1", "grandchild", "child2"},
|
||||
},
|
||||
{
|
||||
// Multiple spans uncollapsed simultaneously: children of all uncollapsed spans are visible at once.
|
||||
//
|
||||
// root
|
||||
// ├─ childA (uncollapsed) → grandchildA ✓
|
||||
// └─ childB (uncollapsed) → grandchildB ✓
|
||||
name: "multiple_uncollapsed",
|
||||
buildRoots: func() ([]*tracedetailtypes.WaterfallSpan, map[string]*tracedetailtypes.WaterfallSpan) {
|
||||
root := mkSpan("root", "svc",
|
||||
mkSpan("childA", "svc", mkSpan("grandchildA", "svc")),
|
||||
mkSpan("childB", "svc", mkSpan("grandchildB", "svc")),
|
||||
)
|
||||
return []*tracedetailtypes.WaterfallSpan{root}, buildSpanMap(root)
|
||||
},
|
||||
uncollapsedSpans: []string{"root", "childA", "childB"},
|
||||
selectedSpanID: "root",
|
||||
wantSpanIDs: []string{"root", "childA", "grandchildA", "childB", "grandchildB"},
|
||||
},
|
||||
{
|
||||
// Collapsing a span with other uncollapsed spans.
|
||||
//
|
||||
// root
|
||||
// ├─ childA (previously expanded — in uncollapsedSpans)
|
||||
// │ ├─ grandchild1 ✓
|
||||
// │ │ └─ greatGrandchild ✗ (grandchild1 not in uncollapsedSpans)
|
||||
// │ └─ grandchild2 ✓
|
||||
// └─ childB ← selected (not expanded)
|
||||
name: "manual_uncollapse",
|
||||
buildRoots: func() ([]*tracedetailtypes.WaterfallSpan, map[string]*tracedetailtypes.WaterfallSpan) {
|
||||
root := mkSpan("root", "svc",
|
||||
mkSpan("childA", "svc",
|
||||
mkSpan("grandchild1", "svc", mkSpan("greatGrandchild", "svc")),
|
||||
mkSpan("grandchild2", "svc"),
|
||||
),
|
||||
mkSpan("childB", "svc"),
|
||||
)
|
||||
return []*tracedetailtypes.WaterfallSpan{root}, buildSpanMap(root)
|
||||
},
|
||||
uncollapsedSpans: []string{"childA"},
|
||||
selectedSpanID: "childB",
|
||||
wantSpanIDs: []string{"root", "childA", "grandchild1", "grandchild2", "childB"},
|
||||
},
|
||||
{
|
||||
// A collapsed span hides all children.
|
||||
name: "collapsed_span_hides_children",
|
||||
buildRoots: func() ([]*tracedetailtypes.WaterfallSpan, map[string]*tracedetailtypes.WaterfallSpan) {
|
||||
root := mkSpan("root", "svc",
|
||||
mkSpan("child1", "svc"),
|
||||
mkSpan("child2", "svc"),
|
||||
)
|
||||
return []*tracedetailtypes.WaterfallSpan{root}, buildSpanMap(root)
|
||||
},
|
||||
uncollapsedSpans: []string{},
|
||||
selectedSpanID: "root",
|
||||
wantSpanIDs: []string{"root"},
|
||||
},
|
||||
{
|
||||
// Selecting a span auto-uncollpases the path from root to that span so it is visible.
|
||||
//
|
||||
// root → parent → selected
|
||||
name: "path_to_selected_is_uncollapsed",
|
||||
buildRoots: func() ([]*tracedetailtypes.WaterfallSpan, map[string]*tracedetailtypes.WaterfallSpan) {
|
||||
root := mkSpan("root", "svc",
|
||||
mkSpan("parent", "svc",
|
||||
mkSpan("selected", "svc"),
|
||||
),
|
||||
)
|
||||
return []*tracedetailtypes.WaterfallSpan{root}, buildSpanMap(root)
|
||||
},
|
||||
uncollapsedSpans: []string{},
|
||||
selectedSpanID: "selected",
|
||||
wantSpanIDs: []string{"root", "parent", "selected"},
|
||||
},
|
||||
{
|
||||
// Siblings of ancestors are rendered as collapsed nodes but their subtrees must NOT be expanded.
|
||||
//
|
||||
// root
|
||||
// ├─ unrelated → unrelated-child (✗)
|
||||
// └─ parent → selected
|
||||
name: "siblings_not_expanded",
|
||||
buildRoots: func() ([]*tracedetailtypes.WaterfallSpan, map[string]*tracedetailtypes.WaterfallSpan) {
|
||||
root := mkSpan("root", "svc",
|
||||
mkSpan("unrelated", "svc", mkSpan("unrelated-child", "svc")),
|
||||
mkSpan("parent", "svc",
|
||||
mkSpan("selected", "svc"),
|
||||
),
|
||||
)
|
||||
return []*tracedetailtypes.WaterfallSpan{root}, buildSpanMap(root)
|
||||
},
|
||||
uncollapsedSpans: []string{},
|
||||
selectedSpanID: "selected",
|
||||
// children of root sort alphabetically: parent < unrelated; unrelated-child stays hidden
|
||||
wantSpanIDs: []string{"root", "parent", "selected", "unrelated"},
|
||||
},
|
||||
{
|
||||
// An unknown selectedSpanID must not panic; returns a window from index 0.
|
||||
name: "unknown_selected_span",
|
||||
buildRoots: func() ([]*tracedetailtypes.WaterfallSpan, map[string]*tracedetailtypes.WaterfallSpan) {
|
||||
root := mkSpan("root", "svc", mkSpan("child", "svc"))
|
||||
return []*tracedetailtypes.WaterfallSpan{root}, buildSpanMap(root)
|
||||
},
|
||||
uncollapsedSpans: []string{},
|
||||
selectedSpanID: "nonexistent",
|
||||
wantSpanIDs: []string{"root"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
roots, spanMap := tc.buildRoots()
|
||||
trace := getWaterfallTrace(roots, spanMap)
|
||||
spans, _ := trace.GetSelectedSpans(tc.uncollapsedSpans, tc.selectedSpanID, 500, 5)
|
||||
assert.Equal(t, tc.wantSpanIDs, spanIDs(spans))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Multiple roots: both trees are flattened into a single pre-order list with
|
||||
// root1's subtree before root2's. Service/entry-point come from the first root.
|
||||
//
|
||||
// root1 svc-a ← selected
|
||||
// └─ child1
|
||||
// root2 svc-b
|
||||
// └─ child2
|
||||
//
|
||||
// Expected output order: root1 → child1 → root2 → child2.
|
||||
func TestGetSelectedSpans_MultipleRoots(t *testing.T) {
|
||||
root1 := mkSpan("root1", "svc-a", mkSpan("child1", "svc-a"))
|
||||
root2 := mkSpan("root2", "svc-b", mkSpan("child2", "svc-b"))
|
||||
spanMap := buildSpanMap(root1, root2)
|
||||
|
||||
trace := getWaterfallTrace([]*tracedetailtypes.WaterfallSpan{root1, root2}, spanMap)
|
||||
spans, _ := trace.GetSelectedSpans([]string{"root1", "root2"}, "root1", 500, 5)
|
||||
|
||||
traceRespnose := tracedetailtypes.NewGettableWaterfallTrace(trace, spans, nil, false)
|
||||
|
||||
assert.Equal(t, []string{"root1", "child1", "root2", "child2"}, spanIDs(spans), "root1 subtree must precede root2 subtree")
|
||||
assert.Equal(t, "svc-a", traceRespnose.RootServiceName, "metadata comes from first root")
|
||||
assert.Equal(t, "root1-op", traceRespnose.RootServiceEntryPoint, "metadata comes from first root")
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// GetSelectedSpans — uncollapsed span tracking
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestGetSelectedSpans_UncollapsedTracking(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
buildRoot func() (*tracedetailtypes.WaterfallSpan, map[string]*tracedetailtypes.WaterfallSpan)
|
||||
uncollapsedSpans []string
|
||||
selectedSpanID string
|
||||
wantSpanIDs []string
|
||||
checkUncollapsed func(t *testing.T, uncollapsed []string)
|
||||
}{
|
||||
{
|
||||
// The path-to-selected spans are returned in updatedUncollapsedSpans.
|
||||
name: "path_returned_in_uncollapsed",
|
||||
buildRoot: func() (*tracedetailtypes.WaterfallSpan, map[string]*tracedetailtypes.WaterfallSpan) {
|
||||
root := mkSpan("root", "svc",
|
||||
mkSpan("parent", "svc",
|
||||
mkSpan("selected", "svc"),
|
||||
),
|
||||
)
|
||||
return root, buildSpanMap(root)
|
||||
},
|
||||
uncollapsedSpans: []string{},
|
||||
selectedSpanID: "selected",
|
||||
wantSpanIDs: []string{"root", "parent", "selected"},
|
||||
checkUncollapsed: func(t *testing.T, uncollapsed []string) {
|
||||
assert.ElementsMatch(t, []string{"root", "parent"}, uncollapsed)
|
||||
},
|
||||
},
|
||||
{
|
||||
// Siblings of ancestors are not tracked as uncollapsed.
|
||||
name: "siblings_not_in_uncollapsed",
|
||||
buildRoot: func() (*tracedetailtypes.WaterfallSpan, map[string]*tracedetailtypes.WaterfallSpan) {
|
||||
root := mkSpan("root", "svc",
|
||||
mkSpan("unrelated", "svc", mkSpan("unrelated-child", "svc")),
|
||||
mkSpan("parent", "svc",
|
||||
mkSpan("selected", "svc"),
|
||||
),
|
||||
)
|
||||
return root, buildSpanMap(root)
|
||||
},
|
||||
uncollapsedSpans: []string{},
|
||||
selectedSpanID: "selected",
|
||||
wantSpanIDs: []string{"root", "parent", "selected", "unrelated"},
|
||||
checkUncollapsed: func(t *testing.T, uncollapsed []string) {
|
||||
assert.ElementsMatch(t, []string{"root", "parent"}, uncollapsed)
|
||||
},
|
||||
},
|
||||
{
|
||||
// Auto-expanded span IDs from ALL branches are returned in updatedUncollapsedSpans.
|
||||
// Only internal nodes (spans with children) are tracked — leaf spans are never added.
|
||||
// root is in uncollapsedSpans, so its children are auto-expanded.
|
||||
//
|
||||
// root (selected, expanded)
|
||||
// ├─ childA (internal ✓)
|
||||
// │ └─ grandchildA (internal ✓)
|
||||
// │ └─ leafA (leaf ✗)
|
||||
// └─ childB (internal ✓)
|
||||
// └─ grandchildB (internal ✓)
|
||||
// └─ leafB (leaf ✗)
|
||||
name: "auto_expanded_spans_returned",
|
||||
buildRoot: func() (*tracedetailtypes.WaterfallSpan, map[string]*tracedetailtypes.WaterfallSpan) {
|
||||
root := mkSpan("root", "svc",
|
||||
mkSpan("childA", "svc",
|
||||
mkSpan("grandchildA", "svc",
|
||||
mkSpan("leafA", "svc"),
|
||||
),
|
||||
),
|
||||
mkSpan("childB", "svc",
|
||||
mkSpan("grandchildB", "svc",
|
||||
mkSpan("leafB", "svc"),
|
||||
),
|
||||
),
|
||||
)
|
||||
return root, buildSpanMap(root)
|
||||
},
|
||||
uncollapsedSpans: []string{"root"},
|
||||
selectedSpanID: "root",
|
||||
checkUncollapsed: func(t *testing.T, uncollapsed []string) {
|
||||
assert.Contains(t, uncollapsed, "root")
|
||||
assert.Contains(t, uncollapsed, "childA", "internal node depth 1, branch A")
|
||||
assert.Contains(t, uncollapsed, "childB", "internal node depth 1, branch B")
|
||||
assert.Contains(t, uncollapsed, "grandchildA", "internal node depth 2, branch A")
|
||||
assert.Contains(t, uncollapsed, "grandchildB", "internal node depth 2, branch B")
|
||||
assert.NotContains(t, uncollapsed, "leafA", "leaf spans are never added to uncollapsedSpans")
|
||||
assert.NotContains(t, uncollapsed, "leafB", "leaf spans are never added to uncollapsedSpans")
|
||||
},
|
||||
},
|
||||
{
|
||||
// If the selected span is already in uncollapsedSpans,
|
||||
// it should appear exactly once in the result.
|
||||
name: "duplicate_in_uncollapsed",
|
||||
buildRoot: func() (*tracedetailtypes.WaterfallSpan, map[string]*tracedetailtypes.WaterfallSpan) {
|
||||
root := mkSpan("root", "svc",
|
||||
mkSpan("selected", "svc", mkSpan("child", "svc")),
|
||||
)
|
||||
return root, buildSpanMap(root)
|
||||
},
|
||||
uncollapsedSpans: []string{"selected"}, // already present
|
||||
selectedSpanID: "selected",
|
||||
checkUncollapsed: func(t *testing.T, uncollapsed []string) {
|
||||
count := 0
|
||||
for _, id := range uncollapsed {
|
||||
if id == "selected" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
assert.Equal(t, 1, count, "should appear once")
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
root, spanMap := tc.buildRoot()
|
||||
trace := getWaterfallTrace([]*tracedetailtypes.WaterfallSpan{root}, spanMap)
|
||||
spans, uncollapsed := trace.GetSelectedSpans(tc.uncollapsedSpans, tc.selectedSpanID, 500, 5)
|
||||
if tc.wantSpanIDs != nil {
|
||||
assert.Equal(t, tc.wantSpanIDs, spanIDs(spans))
|
||||
}
|
||||
if tc.checkUncollapsed != nil {
|
||||
tc.checkUncollapsed(t, uncollapsed)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// GetSelectedSpans — span metadata
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Test to check if Level, HasChildren, and SubTreeNodeCount are populated correctly.
|
||||
//
|
||||
// root level=0, hasChildren=true, subTree=4
|
||||
// child1 level=1, hasChildren=true, subTree=2
|
||||
// grandchild level=2, hasChildren=false, subTree=1
|
||||
// child2 level=1, hasChildren=false, subTree=1
|
||||
func TestGetSelectedSpans_SpanMetadata(t *testing.T) {
|
||||
root := mkSpan("root", "svc",
|
||||
mkSpan("child1", "svc", mkSpan("grandchild", "svc")),
|
||||
mkSpan("child2", "svc"),
|
||||
)
|
||||
spanMap := buildSpanMap(root)
|
||||
trace := getWaterfallTrace([]*tracedetailtypes.WaterfallSpan{root}, spanMap)
|
||||
spans, _ := trace.GetSelectedSpans([]string{"root", "child1"}, "root", 500, 5)
|
||||
|
||||
byID := map[string]*tracedetailtypes.WaterfallSpan{}
|
||||
for _, s := range spans {
|
||||
byID[s.SpanID] = s
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
spanID string
|
||||
wantLevel uint64
|
||||
wantHasChildren bool
|
||||
wantSubTree uint64
|
||||
}{
|
||||
{"root", 0, true, 4},
|
||||
{"child1", 1, true, 2},
|
||||
{"child2", 1, false, 1},
|
||||
{"grandchild", 2, false, 1},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.spanID, func(t *testing.T) {
|
||||
s := byID[tc.spanID]
|
||||
assert.Equal(t, tc.wantLevel, s.Level)
|
||||
assert.Equal(t, tc.wantHasChildren, s.HasChildren)
|
||||
assert.Equal(t, tc.wantSubTree, s.SubTreeNodeCount)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// GetSelectedSpans — windowing
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestGetSelectedSpans_Window(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
selectedSpanID string
|
||||
wantLen int
|
||||
wantFirst string
|
||||
wantLast string
|
||||
wantSelectedPos int
|
||||
}{
|
||||
{
|
||||
// The selected span is centred: 200 spans before it, 300 after (0.4 / 0.6 split).
|
||||
name: "centred_on_selected",
|
||||
selectedSpanID: "span300",
|
||||
wantLen: 500,
|
||||
wantFirst: "span100",
|
||||
wantLast: "span599",
|
||||
wantSelectedPos: 200,
|
||||
},
|
||||
{
|
||||
// When the selected span is near the start, the window shifts right so no
|
||||
// negative index is used — the result is still 500 spans.
|
||||
name: "shifts_at_start",
|
||||
selectedSpanID: "span10",
|
||||
wantLen: 500,
|
||||
wantFirst: "span0",
|
||||
wantSelectedPos: 10,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
root, spanMap, uncollapsed := makeChain(600)
|
||||
trace := getWaterfallTrace([]*tracedetailtypes.WaterfallSpan{root}, spanMap)
|
||||
spans, _ := trace.GetSelectedSpans(uncollapsed, tc.selectedSpanID, 500, 5)
|
||||
|
||||
assert.Equal(t, tc.wantLen, len(spans), "window size")
|
||||
assert.Equal(t, tc.wantFirst, spans[0].SpanID, "first span in window")
|
||||
if tc.wantLast != "" {
|
||||
assert.Equal(t, tc.wantLast, spans[len(spans)-1].SpanID, "last span in window")
|
||||
}
|
||||
assert.Equal(t, tc.selectedSpanID, spans[tc.wantSelectedPos].SpanID, "selected span position")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// GetSelectedSpans — depth limit
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Depth is measured from the selected span, not the trace root.
|
||||
// Ancestors appear via the path-to-root logic, not the depth limit.
|
||||
// Each depth level has two children to confirm the limit is enforced on all
|
||||
// branches, not just the first.
|
||||
//
|
||||
// root
|
||||
// └─ A ancestor ✓ (path-to-root)
|
||||
// └─ selected
|
||||
// ├─ d1a depth 1 ✓
|
||||
// │ ├─ d2a depth 2 ✓
|
||||
// │ │ ├─ d3a depth 3 ✓
|
||||
// │ │ │ ├─ d4a depth 4 ✓
|
||||
// │ │ │ │ ├─ d5a depth 5 ✓
|
||||
// │ │ │ │ │ └─ d6a depth 6 ✗
|
||||
// │ │ │ │ └─ d5b depth 5 ✓
|
||||
// │ │ │ └─ d4b depth 4 ✓
|
||||
// │ │ └─ d3b depth 3 ✓
|
||||
// │ └─ d2b depth 2 ✓
|
||||
// └─ d1b depth 1 ✓
|
||||
func TestGetSelectedSpans_DepthCountedFromSelectedSpan(t *testing.T) {
|
||||
selected := mkSpan("selected", "svc",
|
||||
mkSpan("d1a", "svc",
|
||||
mkSpan("d2a", "svc",
|
||||
mkSpan("d3a", "svc",
|
||||
mkSpan("d4a", "svc",
|
||||
mkSpan("d5a", "svc",
|
||||
mkSpan("d6a", "svc"), // depth 6 — excluded
|
||||
),
|
||||
mkSpan("d5b", "svc"), // depth 5 — included
|
||||
),
|
||||
mkSpan("d4b", "svc"), // depth 4 — included
|
||||
),
|
||||
mkSpan("d3b", "svc"), // depth 3 — included
|
||||
),
|
||||
mkSpan("d2b", "svc"), // depth 2 — included
|
||||
),
|
||||
mkSpan("d1b", "svc"), // depth 1 — included
|
||||
)
|
||||
root := mkSpan("root", "svc", mkSpan("A", "svc", selected))
|
||||
|
||||
spanMap := buildSpanMap(root)
|
||||
trace := getWaterfallTrace([]*tracedetailtypes.WaterfallSpan{root}, spanMap)
|
||||
spans, _ := trace.GetSelectedSpans([]string{"selected"}, "selected", 500, 5)
|
||||
ids := spanIDs(spans)
|
||||
|
||||
assert.Contains(t, ids, "root", "ancestor shown via path-to-root")
|
||||
assert.Contains(t, ids, "A", "ancestor shown via path-to-root")
|
||||
for _, id := range []string{"d1a", "d1b", "d2a", "d2b", "d3a", "d3b", "d4a", "d4b", "d5a", "d5b"} {
|
||||
assert.Contains(t, ids, id, "depth ≤ 5 — must be included")
|
||||
}
|
||||
assert.NotContains(t, ids, "d6a", "depth 6 > limit — excluded")
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// GetAllSpans
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestGetAllSpans(t *testing.T) {
|
||||
root := mkSpan("root", "svc",
|
||||
mkSpan("childA", "svc",
|
||||
mkSpan("grandchildA", "svc",
|
||||
mkSpan("leafA", "svc2"),
|
||||
),
|
||||
),
|
||||
mkSpan("childB", "svc3",
|
||||
mkSpan("grandchildB", "svc",
|
||||
mkSpan("leafB", "svc2"),
|
||||
),
|
||||
),
|
||||
)
|
||||
trace := getWaterfallTrace([]*tracedetailtypes.WaterfallSpan{root}, nil)
|
||||
spans := trace.GetAllSpans()
|
||||
traceResponse := tracedetailtypes.NewGettableWaterfallTrace(trace, spans, nil, true)
|
||||
assert.ElementsMatch(t, spanIDs(spans), []string{"root", "childA", "grandchildA", "leafA", "childB", "grandchildB", "leafB"})
|
||||
assert.Equal(t, "svc", traceResponse.RootServiceName)
|
||||
assert.Equal(t, "root-op", traceResponse.RootServiceEntryPoint)
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/tracedetailtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
// Handler exposes HTTP handlers for trace detail APIs.
|
||||
@@ -15,5 +14,5 @@ type Handler interface {
|
||||
|
||||
// Module defines the business logic for trace detail operations.
|
||||
type Module interface {
|
||||
GetWaterfall(ctx context.Context, orgID valuer.UUID, traceID string, req *tracedetailtypes.WaterfallRequest) (*tracedetailtypes.WaterfallResponse, error)
|
||||
GetWaterfall(ctx context.Context, traceID string, req *tracedetailtypes.PostableWaterfall) (*tracedetailtypes.GettableWaterfallTrace, error)
|
||||
}
|
||||
|
||||
@@ -225,3 +225,14 @@ func (module *getter) GetResetPasswordTokenByOrgIDAndUserID(ctx context.Context,
|
||||
func (module *getter) GetUsersByOrgIDAndRoleID(ctx context.Context, orgID valuer.UUID, roleID valuer.UUID) ([]*types.User, error) {
|
||||
return module.store.GetUsersByOrgIDAndRoleID(ctx, orgID, roleID)
|
||||
}
|
||||
|
||||
func (module *getter) OnBeforeRoleDelete(ctx context.Context, orgID valuer.UUID, roleID valuer.UUID) error {
|
||||
users, err := module.GetUsersByOrgIDAndRoleID(ctx, orgID, roleID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(users) > 0 {
|
||||
return errors.New(errors.TypeInvalidInput, authtypes.ErrCodeRoleHasUserAssignees, "role has active user assignments, remove them before deleting")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -91,6 +91,9 @@ type Getter interface {
|
||||
|
||||
// Gets all the user with role using role id in an org id
|
||||
GetUsersByOrgIDAndRoleID(ctx context.Context, orgID valuer.UUID, roleID valuer.UUID) ([]*types.User, error)
|
||||
|
||||
// OnBeforeRoleDelete checks if any users are assigned to the role and rejects deletion if so.
|
||||
OnBeforeRoleDelete(ctx context.Context, orgID valuer.UUID, roleID valuer.UUID) error
|
||||
}
|
||||
|
||||
type Handler interface {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user