Compare commits

...

30 Commits

Author SHA1 Message Date
grandwizard28
e3650fede7 chore: remove json tags 2026-04-01 02:39:33 +05:30
grandwizard28
523b6b0391 chore: fix formatting 2026-04-01 02:31:57 +05:30
grandwizard28
0b0439d558 refactor(audit): replace Sprintf with strings.Builder in newBody
Handle edge cases where principal email, ID, or resource ID may be
empty. The builder conditionally includes each segment, avoiding
empty parentheses or leading spaces in the audit body.

Add test cases covering all meaningful combinations: success/failure
with full/partial/empty principal, resource ID, and error details.
2026-04-01 02:30:46 +05:30
grandwizard28
719ff9884f style(audit): rename want prefix to expected in test fields 2026-04-01 02:02:21 +05:30
grandwizard28
694b68139b fix(audit): check rw.Write return values in response_test.go 2026-04-01 02:00:34 +05:30
grandwizard28
b6c8e7fc4d test(audit): add unit tests for responseCapture
Test the four meaningful behaviors: success responses don't capture
body, error responses capture body, large error bodies truncate at
4096 bytes, and 204 No Content suppresses writes entirely.
2026-04-01 01:59:00 +05:30
grandwizard28
b6f2dd5207 fix(audit): add CodeUnset, use ErrorCodeFromBody in middleware
Add errors.CodeUnset for responses missing an error code. Update the
audit middleware to use render.ErrorCodeFromBody instead of the removed
render.ErrorFromBody.
2026-04-01 01:46:13 +05:30
grandwizard28
447364ce8b fix(audit): fix gjson path in ErrorCodeFromBody, add tests
Fix ErrorCodeFromBody gjson path from "errors.code" to "error.code"
to match the ErrorResponse JSON structure. Add unit tests for valid
error response and invalid JSON cases.
2026-04-01 01:44:18 +05:30
grandwizard28
6f91ad11c1 fix(audit): update auditorserver test and otlphttp provider for new struct layout
Update newTestEvent in server_test.go to use nested AuditAttributes
and ResourceAttributes. Update otlphttpauditor provider to access
PrincipalOrgID via PrincipalAttributes. Fix godot lint on attribute
section comments.
2026-04-01 01:35:41 +05:30
grandwizard28
53f827dc82 refactor(audit): shorten attribute struct names, drop error message
Rename AuditEventAuditAttributes to AuditAttributes,
AuditEventPrincipalAttributes to PrincipalAttributes, and likewise
for Resource, Error, and Transport. The package prefix already
disambiguates.

Remove ErrorMessage from ErrorAttributes to avoid leaking sensitive
or PII data into audit logs. Error type and code are sufficient for
filtering; investigators can correlate via trace ID.
2026-04-01 01:31:07 +05:30
grandwizard28
b9f122c79a refactor(audit): decompose AuditEvent into attribute sub-structs, add tests
Decompose flat AuditEvent fields into typed sub-structs
(AuditEventAuditAttributes, PrincipalAttributes, ResourceAttributes,
ErrorAttributes, TransportAttributes) each with a constructor and
Put(pcommon.Map) method. Simplify NewAuditEventFromHTTPRequest to
accept authtypes.Claims and oteltrace IDs directly. Simplify the
middleware caller accordingly.

Add unit tests for the factory, outcome boundary, and principal type
derivation.
2026-04-01 01:13:33 +05:30
grandwizard28
69ab71a630 feat(audit): add option.go with AuditDef, Option, and WithAuditDef 2026-04-01 00:22:33 +05:30
grandwizard28
a4b0d2acac refactor(audit): move AuditDef onto Handler interface, consolidate files
Move AuditDef() onto the Handler interface directly. All Handler
implementations now carry it: handler returns the configured def,
healthOpenAPIHandler returns nil. Delete the separate AuditDefProvider
interface and audit.go handler file. Move excludedRoutes check before
audit emission so excluded routes skip both logging and audit.
2026-04-01 00:22:02 +05:30
grandwizard28
8a013312c6 refactor(audit): move error parsing to render.ErrorFromBody and render.ErrorTypeFromStatusCode
Add render.ErrorFromBody to extract errors.JSON from a JSON-encoded
ErrorResponse body, and render.ErrorTypeFromStatusCode to reverse-map
HTTP status codes to error type strings. The middleware now uses these
instead of local duplicates.
2026-04-01 00:22:02 +05:30
grandwizard28
c26bcdfefb refactor(audit): extract NewAuditEventFromHTTPRequest factory into audittypes
Move event construction to audittypes.NewAuditEventFromHTTPRequest with
an AuditEventContext struct for caller-provided fields. The audittypes
layer reads only transport fields from *http.Request and has no mux,
authtypes, or context dependencies. The middleware pre-extracts
principal, trace, error, and route fields before calling the factory.
2026-04-01 00:22:01 +05:30
grandwizard28
529c12ff3f refactor(audit): rename Logging middleware to Audit, merge into single file
Delete logging.go and merge its contents into audit.go. Rename
Logging/NewLogging to Audit/NewAudit. The response.go file with
responseCapture is unchanged.
2026-04-01 00:22:01 +05:30
grandwizard28
bd60854e0a refactor(audit): move audit logic to middleware, merge with logging
Move audit event emission from handler to middleware layer. The handler
package keeps only the AuditDef struct and AuditDefProvider interface.
The logging middleware now handles both request logging and audit event
emission using a single response capture, avoiding double-wrapping.

Rename badResponseLoggingWriter to responseCapture with body capture
on all 4xx/5xx responses (previously only 400 and 5xx).
2026-04-01 00:22:01 +05:30
grandwizard28
58fbd6d5f5 feat(audit): handler-level AuditDef and response-capturing wrapper
Add declarative audit instrumentation to the handler package. Routes
declare an AuditDef alongside OpenAPIDef; the handler automatically
captures the response status/body and emits an audit event via
auditor.Audit() after every request.
2026-04-01 00:22:01 +05:30
Vinicius Lourenço
e2cd203c8f test(k8s-volume-list): mark test as skip due to flakyness (#10787)
Some checks are pending
Release Drafter / update_release_draft (push) Waiting to run
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
2026-03-31 16:57:38 +00:00
Vikrant Gupta
a4c6394542 feat(sqlstore): add support for transaction modes (#10781)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2026-03-31 13:40:06 +00:00
Pandey
71a13b4818 feat(audit): enterprise auditor with licensing gate and OTLP HTTP export (#10776)
* feat(audit): add enterprise auditor with licensing gate and OTLP HTTP export

Implements the enterprise auditor at ee/auditor/otlphttpauditor/.
Composes auditorserver.Server for batching with licensing gate,
OTel SDK LoggerProvider for InstrumentationScope, and otlploghttp
exporter for OTLP HTTP transport.

* fix(audit): address PR review — inline factory, move body+logrecord to audittypes

- Inline NewFactory closure, remove newProviderFunc
- Move buildBody to audittypes.BuildBody
- Move eventToLogRecord to audittypes.ToLogRecord
- go mod tidy for newly direct otel/log deps

* fix(audit): address PR review round 2

- Make ToLogRecord a method on AuditEvent returning sdklog.Record
- Fold buildBody into ToLogRecord as unexported helper
- Remove accumulatingProcessor and LoggerProvider — export directly
  via otlploghttp.Exporter
- Delete body.go and processor.go

* fix(audit): address PR review round 3

- Merge export.go into provider.go
- Add severity/severityText fields to Outcome struct
- Rename buildBody to setBody method on AuditEvent
- Add appendStringIfNotEmpty helper to reduce duplication

* feat(audit): switch to plog + direct HTTP POST for OTLP export

Replace otlploghttp.Exporter + sdklog.Record with plog data model,
ProtoMarshaler, and direct HTTP POST. This properly sets
InstrumentationScope.Name = "signoz.audit" and Resource attributes
on the OTLP payload.

* fix(audit): adopt collector otlphttpexporter pattern for HTTP export

Model the send function after the OTel Collector's otlphttpexporter:
- Bounded response body reads (64KB max)
- Protobuf-encoded Status parsing from error responses
- Proper response body draining on defer
- Detailed error messages with endpoint URL and status details

* refactor(audit): split export logic into export.go, add throttle retry

- Move export, send, and HTTP response helpers to export.go
- Add exporterhelper.NewThrottleRetry for 429/503 with Retry-After
- Parse Retry-After as delay-seconds or HTTP-date per RFC 7231
- Keep provider.go focused on Auditor interface and lifecycle

* feat(audit): add partial success handler and internal retry with backoff

- Parse ExportLogsServiceResponse on 2xx for partial success, log
  warning if log records were rejected
- Internal retry loop with exponential backoff for retryable status
  codes (429, 502, 503, 504) using RetryConfig from auditor config
- Honour Retry-After header (delay-seconds and HTTP-date)
- Store full auditor.Config on provider struct
- Replace exporterhelper.NewThrottleRetry with local retryableError
  type consumed by the internal retry loop

* fix(audit): fix lint — use pkg/errors, remove stdlib errors and fmt.Errorf

* refactor(audit): use provider as receiver name instead of p

* refactor(audit): clean up enterprise auditor implementation

- Extract retry logic into retry.go
- Move NewPLogsFromAuditEvents and ToLogRecord into event.go
- Add ErrCodeAuditExportFailed to auditor package
- Add version.Build to provider for service.version attribute
- Simplify sendOnce, split response handling into onSuccess/onErr
- Use PrincipalOrgID as valuer.UUID directly
- Use OTLPHTTP.Endpoint as URL type
- Remove gzip compression handling
- Delete logrecord.go

* refactor(audit): use pkg/http/client instead of bare http.Client

Use the standard SigNoz HTTP client with OTel instrumentation.
Disable heimdall retries (count=0) since we have our own
OTLP-aware retry loop that understands Retry-After headers.

* refactor(audit): use heimdall Retriable for retry instead of manual loop

- Implement retrier with exponential backoff from auditor RetryConfig
- Compute retry count from MaxElapsedTime and backoff intervals
- Pass retrier and retry count to pkg/http/client via WithRetriable
- Remove manual retry loop, retryableError type, and Retry-After parsing
- Heimdall handles retries on >= 500 status codes automatically

* refactor(audit): rename retry.go to retrier.go
2026-03-31 13:37:20 +00:00
Vinicius Lourenço
a8e2155bb6 fix(app-routes-redirect): redirects when workspaceBlocked & onboarding having wrong redirects (#10738)
* fix(private): issues with redirect when onboarding & workspace locked

* test(app-routes): add tests for private route
2026-03-31 11:07:42 +00:00
Vinicius Lourenço
a9cbf9a4df fix(infra-monitoring): request loop when click to visualize volume (#10657)
* fix(infra-monitoring): request loop when click to visualize volume

* test(k8s-volume-list): fix tests broken due to nuqs
2026-03-31 10:56:09 +00:00
Vikrant Gupta
2163e1ce41 chore(lint): enable godot and staticcheck (#10775)
* chore(lint): enable godot and staticcheck

* chore(lint): merge main and fix new lint issues in main
2026-03-31 09:11:49 +00:00
Srikanth Chekuri
b9eecacab7 chore: remove v1 metrics explorer code (#10764)
* chore: remove v1 metrics explorer code

* chore: fix ci

* chore: fix tests

* chore: address review comments
2026-03-31 08:46:27 +00:00
Tushar Vats
13982033dc fix: handle empty not() expression (#10165)
* fix: handle empty not() expression

* fix: handle more cases

* fix: short circuit conditions and updated unit tests

* fix: revert commented code

* fix: added more unit tests

* fix: added integration tests

* fix: make py-lint and make py-fmt

* fix: moved from traces to logs for testing full text search

* fix: simplify code

* fix: added more unit tests

* fix: addressed comments

* fix: update comment

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

* fix: update unit test

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

* fix: update unit test

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

* fix: instead of using true, using a skip literal

* fix: unit test

* fix: update integration test

* fix: update unit for relevance

* fix: lint error

* fix: added a new literal for error condition, added more unit tests

* fix: merge issues

* fix: inline comments

* fix: update unit tests merging from main

* fix: make py-fmt and make py-lint

* fix: type handling

---------

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2026-03-31 08:44:07 +00:00
Piyush Singariya
b198cfc11a chore: enable JSON Path index in JSON Logs (#10736)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* feat: enable JSON Path index

* fix: contextual path index usage

* test: fix unit tests
2026-03-31 08:07:27 +00:00
Amaresh S M
7b6f77bd52 fix(devenv): fix otel-collector startup failure (#10620)
Co-authored-by: Nageshbansal <76246968+Nageshbansal@users.noreply.github.com>
2026-03-31 07:08:41 +00:00
Pandey
e588c57e44 feat(audit): add noop auditor for community edition (#10769)
* feat(audit): add noop auditor and embed ServiceWithHealthy in Auditor

Embed factory.ServiceWithHealthy in the Auditor interface so all
providers (noop and future OTLP HTTP) share a uniform lifecycle
contract. Add pkg/auditor/noopauditor for the community edition
that silently discards all events with zero allocations.

* feat(audit): remove noopauditor test file
2026-03-31 06:16:20 +00:00
Ayush Shukla
98f53423dc docs: fix typo 'versinoing' -> 'versioning' in frontend README (#10765) 2026-03-31 05:05:23 +00:00
286 changed files with 17530 additions and 5282 deletions

View File

@@ -27,8 +27,8 @@ services:
- ${PWD}/fs/tmp/var/lib/clickhouse/user_scripts/:/var/lib/clickhouse/user_scripts/
- ${PWD}/../../../deploy/common/clickhouse/custom-function.xml:/etc/clickhouse-server/custom-function.xml
ports:
- '127.0.0.1:8123:8123'
- '127.0.0.1:9000:9000'
- "127.0.0.1:8123:8123"
- "127.0.0.1:9000:9000"
tty: true
healthcheck:
test:
@@ -47,13 +47,16 @@ services:
condition: service_healthy
environment:
- CLICKHOUSE_SKIP_USER_SETUP=1
networks:
- default
- signoz-devenv
zookeeper:
image: signoz/zookeeper:3.7.1
container_name: zookeeper
volumes:
- ${PWD}/fs/tmp/zookeeper:/bitnami/zookeeper
ports:
- '127.0.0.1:2181:2181'
- "127.0.0.1:2181:2181"
environment:
- ALLOW_ANONYMOUS_LOGIN=yes
healthcheck:
@@ -74,12 +77,19 @@ services:
entrypoint:
- /bin/sh
command:
- -c
- |
/signoz-otel-collector migrate bootstrap &&
/signoz-otel-collector migrate sync up &&
/signoz-otel-collector migrate async up
- -c
- |
/signoz-otel-collector migrate bootstrap &&
/signoz-otel-collector migrate sync up &&
/signoz-otel-collector migrate async up
depends_on:
clickhouse:
condition: service_healthy
restart: on-failure
networks:
- default
- signoz-devenv
networks:
signoz-devenv:
name: signoz-devenv

View File

@@ -3,7 +3,7 @@ services:
image: signoz/signoz-otel-collector:v0.142.0
container_name: signoz-otel-collector-dev
entrypoint:
- /bin/sh
- /bin/sh
command:
- -c
- |
@@ -34,4 +34,11 @@ services:
retries: 3
restart: unless-stopped
extra_hosts:
- "host.docker.internal:host-gateway"
- "host.docker.internal:host-gateway"
networks:
- default
- signoz-devenv
networks:
signoz-devenv:
name: signoz-devenv

View File

@@ -12,10 +12,10 @@ receivers:
scrape_configs:
- job_name: otel-collector
static_configs:
- targets:
- localhost:8888
labels:
job_name: otel-collector
- targets:
- localhost:8888
labels:
job_name: otel-collector
processors:
batch:
@@ -29,7 +29,26 @@ processors:
signozspanmetrics/delta:
metrics_exporter: signozclickhousemetrics
metrics_flush_interval: 60s
latency_histogram_buckets: [100us, 1ms, 2ms, 6ms, 10ms, 50ms, 100ms, 250ms, 500ms, 1000ms, 1400ms, 2000ms, 5s, 10s, 20s, 40s, 60s ]
latency_histogram_buckets:
[
100us,
1ms,
2ms,
6ms,
10ms,
50ms,
100ms,
250ms,
500ms,
1000ms,
1400ms,
2000ms,
5s,
10s,
20s,
40s,
60s,
]
dimensions_cache_size: 100000
aggregation_temporality: AGGREGATION_TEMPORALITY_DELTA
enable_exp_histogram: true
@@ -60,13 +79,13 @@ extensions:
exporters:
clickhousetraces:
datasource: tcp://host.docker.internal:9000/signoz_traces
datasource: tcp://clickhouse:9000/signoz_traces
low_cardinal_exception_grouping: ${env:LOW_CARDINAL_EXCEPTION_GROUPING}
use_new_schema: true
signozclickhousemetrics:
dsn: tcp://host.docker.internal:9000/signoz_metrics
dsn: tcp://clickhouse:9000/signoz_metrics
clickhouselogsexporter:
dsn: tcp://host.docker.internal:9000/signoz_logs
dsn: tcp://clickhouse:9000/signoz_logs
timeout: 10s
use_new_schema: true
@@ -93,4 +112,4 @@ service:
logs:
receivers: [otlp]
processors: [batch]
exporters: [clickhouselogsexporter]
exporters: [clickhouselogsexporter]

View File

@@ -6,12 +6,14 @@ linters:
- depguard
- errcheck
- forbidigo
- godot
- govet
- iface
- ineffassign
- misspell
- nilnil
- sloglint
- staticcheck
- wastedassign
- unparam
- unused

View File

@@ -85,10 +85,12 @@ sqlstore:
sqlite:
# The path to the SQLite database file.
path: /var/lib/signoz/signoz.db
# Mode is the mode to use for the sqlite database.
# The journal mode for the sqlite database. Supported values: delete, wal.
mode: delete
# BusyTimeout is the timeout for the sqlite database to wait for a lock.
# The timeout for the sqlite database to wait for a lock.
busy_timeout: 10s
# The default transaction locking behavior. Supported values: deferred, immediate, exclusive.
transaction_mode: deferred
##################### APIServer #####################
apiserver:

View File

@@ -1114,6 +1114,33 @@ components:
enabled:
type: boolean
type: object
MetricsexplorertypesInspectMetricsRequest:
properties:
end:
format: int64
type: integer
filter:
$ref: '#/components/schemas/Querybuildertypesv5Filter'
metricName:
type: string
start:
format: int64
type: integer
required:
- metricName
- start
- end
type: object
MetricsexplorertypesInspectMetricsResponse:
properties:
series:
items:
$ref: '#/components/schemas/Querybuildertypesv5TimeSeries'
nullable: true
type: array
required:
- series
type: object
MetricsexplorertypesListMetric:
properties:
description:
@@ -1262,6 +1289,13 @@ components:
- temporality
- isMonotonic
type: object
MetricsexplorertypesMetricsOnboardingResponse:
properties:
hasMetrics:
type: boolean
required:
- hasMetrics
type: object
MetricsexplorertypesStat:
properties:
description:
@@ -7750,6 +7784,111 @@ paths:
summary: Update metric metadata
tags:
- metrics
/api/v2/metrics/inspect:
post:
deprecated: false
description: Returns raw time series data points for a metric within a time
range (max 30 minutes). Each series includes labels and timestamp/value pairs.
operationId: InspectMetrics
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/MetricsexplorertypesInspectMetricsRequest'
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/MetricsexplorertypesInspectMetricsResponse'
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: Inspect raw metric data points
tags:
- metrics
/api/v2/metrics/onboarding:
get:
deprecated: false
description: Lightweight endpoint that checks if any non-SigNoz metrics have
been ingested, used for onboarding status detection
operationId: GetMetricsOnboardingStatus
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/MetricsexplorertypesMetricsOnboardingResponse'
status:
type: string
required:
- status
- data
type: object
description: OK
"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: Check if non-SigNoz metrics have been received
tags:
- metrics
/api/v2/metrics/stats:
post:
deprecated: false

View File

@@ -16,7 +16,7 @@ func (hp *HourlyProvider) GetBaseSeasonalProvider() *BaseSeasonalProvider {
return &hp.BaseSeasonalProvider
}
// NewHourlyProvider now uses the generic option type
// NewHourlyProvider now uses the generic option type.
func NewHourlyProvider(opts ...GenericProviderOption[*HourlyProvider]) *HourlyProvider {
hp := &HourlyProvider{
BaseSeasonalProvider: BaseSeasonalProvider{},

View File

@@ -47,7 +47,7 @@ type AnomaliesResponse struct {
// | |
// (rounded value for past peiod) + (seasonal growth)
//
// score = abs(value - prediction) / stddev (current_season_query)
// score = abs(value - prediction) / stddev (current_season_query).
type anomalyQueryParams struct {
// CurrentPeriodQuery is the query range params for period user is looking at or eval window
// Example: (now-5m, now), (now-30m, now), (now-1h, now)

View File

@@ -18,12 +18,12 @@ var (
movingAvgWindowSize = 7
)
// BaseProvider is an interface that includes common methods for all provider types
// BaseProvider is an interface that includes common methods for all provider types.
type BaseProvider interface {
GetBaseSeasonalProvider() *BaseSeasonalProvider
}
// GenericProviderOption is a generic type for provider options
// GenericProviderOption is a generic type for provider options.
type GenericProviderOption[T BaseProvider] func(T)
func WithQuerier[T BaseProvider](querier querier.Querier) GenericProviderOption[T] {
@@ -121,7 +121,7 @@ func (p *BaseSeasonalProvider) getResults(ctx context.Context, orgID valuer.UUID
}
// getMatchingSeries gets the matching series from the query result
// for the given series
// for the given series.
func (p *BaseSeasonalProvider) getMatchingSeries(_ context.Context, queryResult *qbtypes.TimeSeriesData, series *qbtypes.TimeSeries) *qbtypes.TimeSeries {
if queryResult == nil || len(queryResult.Aggregations) == 0 || len(queryResult.Aggregations[0].Series) == 0 {
return nil
@@ -155,13 +155,14 @@ func (p *BaseSeasonalProvider) getStdDev(series *qbtypes.TimeSeries) float64 {
avg := p.getAvg(series)
var sum float64
for _, smpl := range series.Values {
sum += math.Pow(smpl.Value-avg, 2)
d := smpl.Value - avg
sum += d * d
}
return math.Sqrt(sum / float64(len(series.Values)))
}
// getMovingAvg gets the moving average for the given series
// for the given window size and start index
// for the given window size and start index.
func (p *BaseSeasonalProvider) getMovingAvg(series *qbtypes.TimeSeries, movingAvgWindowSize, startIdx int) float64 {
if series == nil || len(series.Values) == 0 {
return 0
@@ -236,7 +237,7 @@ func (p *BaseSeasonalProvider) getPredictedSeries(
// getBounds gets the upper and lower bounds for the given series
// for the given z score threshold
// moving avg of the previous period series + z score threshold * std dev of the series
// moving avg of the previous period series - z score threshold * std dev of the series
// moving avg of the previous period series - z score threshold * std dev of the series.
func (p *BaseSeasonalProvider) getBounds(
series, predictedSeries, weekSeries *qbtypes.TimeSeries,
zScoreThreshold float64,
@@ -269,7 +270,7 @@ func (p *BaseSeasonalProvider) getBounds(
// getExpectedValue gets the expected value for the given series
// for the given index
// prevSeriesAvg + currentSeasonSeriesAvg - mean of past season series, past2 season series and past3 season series
// prevSeriesAvg + currentSeasonSeriesAvg - mean of past season series, past2 season series and past3 season series.
func (p *BaseSeasonalProvider) getExpectedValue(
_, prevSeries, currentSeasonSeries, pastSeasonSeries, past2SeasonSeries, past3SeasonSeries *qbtypes.TimeSeries, idx int,
) float64 {
@@ -283,7 +284,7 @@ func (p *BaseSeasonalProvider) getExpectedValue(
// getScore gets the anomaly score for the given series
// for the given index
// (value - expectedValue) / std dev of the series
// (value - expectedValue) / std dev of the series.
func (p *BaseSeasonalProvider) getScore(
series, prevSeries, weekSeries, weekPrevSeries, past2SeasonSeries, past3SeasonSeries *qbtypes.TimeSeries, value float64, idx int,
) float64 {
@@ -296,7 +297,7 @@ func (p *BaseSeasonalProvider) getScore(
// getAnomalyScores gets the anomaly scores for the given series
// for the given index
// (value - expectedValue) / std dev of the series
// (value - expectedValue) / std dev of the series.
func (p *BaseSeasonalProvider) getAnomalyScores(
series, prevSeries, currentSeasonSeries, pastSeasonSeries, past2SeasonSeries, past3SeasonSeries *qbtypes.TimeSeries,
) *qbtypes.TimeSeries {

View File

@@ -0,0 +1,143 @@
package otlphttpauditor
import (
"bytes"
"context"
"io"
"log/slog"
"net/http"
"github.com/SigNoz/signoz/pkg/auditor"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/audittypes"
collogspb "go.opentelemetry.io/proto/otlp/collector/logs/v1"
"google.golang.org/protobuf/proto"
spb "google.golang.org/genproto/googleapis/rpc/status"
)
const (
maxHTTPResponseReadBytes int64 = 64 * 1024
protobufContentType string = "application/x-protobuf"
)
func (provider *provider) export(ctx context.Context, events []audittypes.AuditEvent) error {
logs := audittypes.NewPLogsFromAuditEvents(events, "signoz", provider.build.Version(), "signoz.audit")
request, err := provider.marshaler.MarshalLogs(logs)
if err != nil {
return errors.Wrapf(err, errors.TypeInternal, auditor.ErrCodeAuditExportFailed, "failed to marshal audit logs")
}
if err := provider.send(ctx, request); err != nil {
provider.settings.Logger().ErrorContext(ctx, "audit export failed", errors.Attr(err), slog.Int("dropped_log_records", len(events)))
return err
}
return nil
}
// Posts a protobuf-encoded OTLP request to the configured endpoint.
// Retries are handled by the underlying heimdall HTTP client.
// Ref: https://github.com/open-telemetry/opentelemetry-collector/blob/main/exporter/otlphttpexporter/otlp.go
func (provider *provider) send(ctx context.Context, body []byte) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, provider.config.OTLPHTTP.Endpoint.String(), bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", protobufContentType)
res, err := provider.httpClient.Do(req)
if err != nil {
return err
}
defer func() {
_, _ = io.CopyN(io.Discard, res.Body, maxHTTPResponseReadBytes)
res.Body.Close()
}()
if res.StatusCode >= 200 && res.StatusCode <= 299 {
provider.onSuccess(ctx, res)
return nil
}
return provider.onErr(res)
}
// Ref: https://github.com/open-telemetry/opentelemetry-collector/blob/01b07fcbb7a253bd996c290dcae6166e71d13732/exporter/otlphttpexporter/otlp.go#L403.
func (provider *provider) onSuccess(ctx context.Context, res *http.Response) {
resBytes, err := readResponseBody(res)
if err != nil || resBytes == nil {
return
}
exportResponse := &collogspb.ExportLogsServiceResponse{}
if err := proto.Unmarshal(resBytes, exportResponse); err != nil {
return
}
ps := exportResponse.GetPartialSuccess()
if ps == nil {
return
}
if ps.GetErrorMessage() != "" || ps.GetRejectedLogRecords() != 0 {
provider.settings.Logger().WarnContext(ctx, "partial success response", slog.String("message", ps.GetErrorMessage()), slog.Int64("dropped_log_records", ps.GetRejectedLogRecords()))
}
}
func (provider *provider) onErr(res *http.Response) error {
status := readResponseStatus(res)
if status != nil {
return errors.Newf(errors.TypeInternal, auditor.ErrCodeAuditExportFailed, "request to %s responded with status code %d, Message=%s, Details=%v", provider.config.OTLPHTTP.Endpoint.String(), res.StatusCode, status.Message, status.Details)
}
return errors.Newf(errors.TypeInternal, auditor.ErrCodeAuditExportFailed, "request to %s responded with status code %d", provider.config.OTLPHTTP.Endpoint.String(), res.StatusCode)
}
// Reads at most maxHTTPResponseReadBytes from the response body.
// Ref: https://github.com/open-telemetry/opentelemetry-collector/blob/01b07fcbb7a253bd996c290dcae6166e71d13732/exporter/otlphttpexporter/otlp.go#L275.
func readResponseBody(resp *http.Response) ([]byte, error) {
if resp.ContentLength == 0 {
return nil, nil
}
maxRead := resp.ContentLength
if maxRead == -1 || maxRead > maxHTTPResponseReadBytes {
maxRead = maxHTTPResponseReadBytes
}
protoBytes := make([]byte, maxRead)
n, err := io.ReadFull(resp.Body, protoBytes)
if n == 0 && (err == nil || errors.Is(err, io.EOF)) {
return nil, nil
}
if err != nil && !errors.Is(err, io.ErrUnexpectedEOF) {
return nil, err
}
return protoBytes[:n], nil
}
// Decodes a protobuf-encoded Status from 4xx/5xx response bodies. Returns nil if the response is empty or cannot be decoded.
// Ref: https://github.com/open-telemetry/opentelemetry-collector/blob/01b07fcbb7a253bd996c290dcae6166e71d13732/exporter/otlphttpexporter/otlp.go#L310.
func readResponseStatus(resp *http.Response) *spb.Status {
if resp.StatusCode < 400 || resp.StatusCode > 599 {
return nil
}
respBytes, err := readResponseBody(resp)
if err != nil || respBytes == nil {
return nil
}
respStatus := &spb.Status{}
if err := proto.Unmarshal(respBytes, respStatus); err != nil {
return nil
}
return respStatus
}

View File

@@ -0,0 +1,97 @@
package otlphttpauditor
import (
"context"
"github.com/SigNoz/signoz/pkg/auditor"
"github.com/SigNoz/signoz/pkg/auditor/auditorserver"
"github.com/SigNoz/signoz/pkg/factory"
client "github.com/SigNoz/signoz/pkg/http/client"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/types/audittypes"
"github.com/SigNoz/signoz/pkg/version"
"go.opentelemetry.io/collector/pdata/plog"
)
var _ auditor.Auditor = (*provider)(nil)
type provider struct {
settings factory.ScopedProviderSettings
config auditor.Config
licensing licensing.Licensing
build version.Build
server *auditorserver.Server
marshaler plog.ProtoMarshaler
httpClient *client.Client
}
func NewFactory(licensing licensing.Licensing, build version.Build) factory.ProviderFactory[auditor.Auditor, auditor.Config] {
return factory.NewProviderFactory(factory.MustNewName("otlphttp"), func(ctx context.Context, providerSettings factory.ProviderSettings, config auditor.Config) (auditor.Auditor, error) {
return newProvider(ctx, providerSettings, config, licensing, build)
})
}
func newProvider(_ context.Context, providerSettings factory.ProviderSettings, config auditor.Config, licensing licensing.Licensing, build version.Build) (auditor.Auditor, error) {
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/ee/auditor/otlphttpauditor")
httpClient, err := client.New(
settings.Logger(),
providerSettings.TracerProvider,
providerSettings.MeterProvider,
client.WithTimeout(config.OTLPHTTP.Timeout),
client.WithRetryCount(retryCountFromConfig(config.OTLPHTTP.Retry)),
retrierOption(config.OTLPHTTP.Retry),
)
if err != nil {
return nil, err
}
provider := &provider{
settings: settings,
config: config,
licensing: licensing,
build: build,
marshaler: plog.ProtoMarshaler{},
httpClient: httpClient,
}
server, err := auditorserver.New(settings,
auditorserver.Config{
BufferSize: config.BufferSize,
BatchSize: config.BatchSize,
FlushInterval: config.FlushInterval,
},
provider.export,
)
if err != nil {
return nil, err
}
provider.server = server
return provider, nil
}
func (provider *provider) Start(ctx context.Context) error {
return provider.server.Start(ctx)
}
func (provider *provider) Audit(ctx context.Context, event audittypes.AuditEvent) {
if event.PrincipalAttributes.PrincipalOrgID.IsZero() {
provider.settings.Logger().WarnContext(ctx, "audit event dropped as org_id is zero")
return
}
if _, err := provider.licensing.GetActive(ctx, event.PrincipalAttributes.PrincipalOrgID); err != nil {
return
}
provider.server.Add(ctx, event)
}
func (provider *provider) Healthy() <-chan struct{} {
return provider.server.Healthy()
}
func (provider *provider) Stop(ctx context.Context) error {
return provider.server.Stop(ctx)
}

View File

@@ -0,0 +1,52 @@
package otlphttpauditor
import (
"time"
"github.com/SigNoz/signoz/pkg/auditor"
client "github.com/SigNoz/signoz/pkg/http/client"
)
// retrier implements client.Retriable with exponential backoff
// derived from auditor.RetryConfig.
type retrier struct {
initialInterval time.Duration
maxInterval time.Duration
}
func newRetrier(cfg auditor.RetryConfig) *retrier {
return &retrier{
initialInterval: cfg.InitialInterval,
maxInterval: cfg.MaxInterval,
}
}
// NextInterval returns the backoff duration for the given retry attempt.
// Uses exponential backoff: initialInterval * 2^retry, capped at maxInterval.
func (r *retrier) NextInterval(retry int) time.Duration {
interval := r.initialInterval
for range retry {
interval *= 2
}
return min(interval, r.maxInterval)
}
func retrierOption(cfg auditor.RetryConfig) client.Option {
return client.WithRetriable(newRetrier(cfg))
}
func retryCountFromConfig(cfg auditor.RetryConfig) int {
if !cfg.Enabled || cfg.MaxElapsedTime <= 0 {
return 0
}
count := 0
elapsed := time.Duration(0)
interval := cfg.InitialInterval
for elapsed < cfg.MaxElapsedTime {
elapsed += interval
interval = min(interval*2, cfg.MaxInterval)
count++
}
return count
}

View File

@@ -13,7 +13,7 @@ var (
once sync.Once
)
// initializes the licensing configuration
// Config initializes the licensing configuration.
func Config(pollInterval time.Duration, failureThreshold int) licensing.Config {
once.Do(func() {
config = licensing.Config{PollInterval: pollInterval, FailureThreshold: failureThreshold}

View File

@@ -79,7 +79,7 @@ func (h *handler) QueryRange(rw http.ResponseWriter, req *http.Request) {
// Build step intervals from the anomaly query
stepIntervals := make(map[string]uint64)
if anomalyQuery.StepInterval.Duration > 0 {
stepIntervals[anomalyQuery.Name] = uint64(anomalyQuery.StepInterval.Duration.Seconds())
stepIntervals[anomalyQuery.Name] = uint64(anomalyQuery.StepInterval.Seconds())
}
finalResp := &qbtypes.QueryRangeResponse{

View File

@@ -229,7 +229,7 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h
s.config.APIServer.Timeout.Default,
s.config.APIServer.Timeout.Max,
).Wrap)
r.Use(middleware.NewLogging(s.signoz.Instrumentation.Logger(), s.config.APIServer.Logging.ExcludedRoutes).Wrap)
r.Use(middleware.NewAudit(s.signoz.Instrumentation.Logger(), s.config.APIServer.Logging.ExcludedRoutes, nil).Wrap)
r.Use(middleware.NewComment().Wrap)
apiHandler.RegisterRoutes(r, am)
@@ -242,7 +242,6 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h
apiHandler.RegisterWebSocketPaths(r, am)
apiHandler.RegisterMessagingQueuesRoutes(r, am)
apiHandler.RegisterThirdPartyApiRoutes(r, am)
apiHandler.MetricExplorerRoutes(r, am)
apiHandler.RegisterTraceFunnelsRoutes(r, am)
err := s.signoz.APIServer.AddToRouter(r)

View File

@@ -336,9 +336,10 @@ func (dialect *dialect) UpdatePrimaryKey(ctx context.Context, bun bun.IDB, oldMo
}
fkReference := ""
if reference == Org {
switch reference {
case Org:
fkReference = OrgReference
} else if reference == User {
case User:
fkReference = UserReference
}
@@ -392,9 +393,10 @@ func (dialect *dialect) AddPrimaryKey(ctx context.Context, bun bun.IDB, oldModel
}
fkReference := ""
if reference == Org {
switch reference {
case Org:
fkReference = OrgReference
} else if reference == User {
case User:
fkReference = UserReference
}

View File

@@ -19,7 +19,7 @@ var (
once sync.Once
)
// initializes the Zeus configuration
// initializes the Zeus configuration.
func Config() zeus.Config {
once.Do(func() {
parsedURL, err := neturl.Parse(url)

View File

@@ -189,7 +189,7 @@ func (provider *Provider) do(ctx context.Context, url *url.URL, method string, k
return nil, provider.errFromStatusCode(response.StatusCode, errorMessage)
}
// This can be taken down to the client package
// This can be taken down to the client package.
func (provider *Provider) errFromStatusCode(statusCode int, errorMessage string) error {
switch statusCode {
case http.StatusBadRequest:

View File

@@ -12,7 +12,7 @@
or
`docker build . -t tagname`
**Tag to remote url- Introduce versinoing later on**
**Tag to remote url- Introduce versioning later on**
```
docker tag signoz/frontend:latest 7296823551/signoz:latest

View File

@@ -101,6 +101,22 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
preference.name === ORG_PREFERENCES.ORG_ONBOARDING,
)?.value;
// Don't redirect to onboarding if workspace has issues (blocked, suspended, or restricted)
// User needs access to settings/billing to fix payment issues
const isWorkspaceBlocked = trialInfo?.workSpaceBlock;
const isWorkspaceSuspended = activeLicense?.state === LicenseState.DEFAULTED;
const isWorkspaceAccessRestricted =
activeLicense?.state === LicenseState.TERMINATED ||
activeLicense?.state === LicenseState.EXPIRED ||
activeLicense?.state === LicenseState.CANCELLED;
const hasWorkspaceIssue =
isWorkspaceBlocked || isWorkspaceSuspended || isWorkspaceAccessRestricted;
if (hasWorkspaceIssue) {
return;
}
const isFirstUser = checkFirstTimeUser();
if (
isFirstUser &&
@@ -119,40 +135,36 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
orgPreferences,
usersData,
pathname,
trialInfo?.workSpaceBlock,
activeLicense?.state,
]);
const navigateToWorkSpaceBlocked = (route: any): void => {
const { path } = route;
const navigateToWorkSpaceBlocked = useCallback((): void => {
const isRouteEnabledForWorkspaceBlockedState =
isAdmin &&
(path === ROUTES.SETTINGS ||
path === ROUTES.ORG_SETTINGS ||
path === ROUTES.MEMBERS_SETTINGS ||
path === ROUTES.BILLING ||
path === ROUTES.MY_SETTINGS);
(pathname === ROUTES.SETTINGS ||
pathname === ROUTES.ORG_SETTINGS ||
pathname === ROUTES.MEMBERS_SETTINGS ||
pathname === ROUTES.BILLING ||
pathname === ROUTES.MY_SETTINGS);
if (
path &&
path !== ROUTES.WORKSPACE_LOCKED &&
pathname &&
pathname !== ROUTES.WORKSPACE_LOCKED &&
!isRouteEnabledForWorkspaceBlockedState
) {
history.push(ROUTES.WORKSPACE_LOCKED);
}
};
}, [isAdmin, pathname]);
const navigateToWorkSpaceAccessRestricted = (route: any): void => {
const { path } = route;
if (path && path !== ROUTES.WORKSPACE_ACCESS_RESTRICTED) {
const navigateToWorkSpaceAccessRestricted = useCallback((): void => {
if (pathname && pathname !== ROUTES.WORKSPACE_ACCESS_RESTRICTED) {
history.push(ROUTES.WORKSPACE_ACCESS_RESTRICTED);
}
};
}, [pathname]);
useEffect(() => {
if (!isFetchingActiveLicense && activeLicense) {
const currentRoute = mapRoutes.get('current');
const isTerminated = activeLicense.state === LicenseState.TERMINATED;
const isExpired = activeLicense.state === LicenseState.EXPIRED;
const isCancelled = activeLicense.state === LicenseState.CANCELLED;
@@ -161,61 +173,53 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
const { platform } = activeLicense;
if (
isWorkspaceAccessRestricted &&
platform === LicensePlatform.CLOUD &&
currentRoute
) {
navigateToWorkSpaceAccessRestricted(currentRoute);
if (isWorkspaceAccessRestricted && platform === LicensePlatform.CLOUD) {
navigateToWorkSpaceAccessRestricted();
}
}
}, [isFetchingActiveLicense, activeLicense, mapRoutes, pathname]);
}, [
isFetchingActiveLicense,
activeLicense,
navigateToWorkSpaceAccessRestricted,
]);
useEffect(() => {
if (!isFetchingActiveLicense) {
const currentRoute = mapRoutes.get('current');
const shouldBlockWorkspace = trialInfo?.workSpaceBlock;
if (
shouldBlockWorkspace &&
currentRoute &&
activeLicense?.platform === LicensePlatform.CLOUD
) {
navigateToWorkSpaceBlocked(currentRoute);
navigateToWorkSpaceBlocked();
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
isFetchingActiveLicense,
trialInfo?.workSpaceBlock,
activeLicense?.platform,
mapRoutes,
pathname,
navigateToWorkSpaceBlocked,
]);
const navigateToWorkSpaceSuspended = (route: any): void => {
const { path } = route;
if (path && path !== ROUTES.WORKSPACE_SUSPENDED) {
const navigateToWorkSpaceSuspended = useCallback((): void => {
if (pathname && pathname !== ROUTES.WORKSPACE_SUSPENDED) {
history.push(ROUTES.WORKSPACE_SUSPENDED);
}
};
}, [pathname]);
useEffect(() => {
if (!isFetchingActiveLicense && activeLicense) {
const currentRoute = mapRoutes.get('current');
const shouldSuspendWorkspace =
activeLicense.state === LicenseState.DEFAULTED;
if (
shouldSuspendWorkspace &&
currentRoute &&
activeLicense.platform === LicensePlatform.CLOUD
) {
navigateToWorkSpaceSuspended(currentRoute);
navigateToWorkSpaceSuspended();
}
}
}, [isFetchingActiveLicense, activeLicense, mapRoutes, pathname]);
}, [isFetchingActiveLicense, activeLicense, navigateToWorkSpaceSuspended]);
useEffect(() => {
if (org && org.length > 0 && org[0].id !== undefined) {

File diff suppressed because it is too large Load Diff

View File

@@ -31,10 +31,13 @@ import type {
GetMetricHighlightsPathParameters,
GetMetricMetadata200,
GetMetricMetadataPathParameters,
GetMetricsOnboardingStatus200,
GetMetricsStats200,
GetMetricsTreemap200,
InspectMetrics200,
ListMetrics200,
ListMetricsParams,
MetricsexplorertypesInspectMetricsRequestDTO,
MetricsexplorertypesStatsRequestDTO,
MetricsexplorertypesTreemapRequestDTO,
MetricsexplorertypesUpdateMetricMetadataRequestDTO,
@@ -778,6 +781,176 @@ export const useUpdateMetricMetadata = <
return useMutation(mutationOptions);
};
/**
* Returns raw time series data points for a metric within a time range (max 30 minutes). Each series includes labels and timestamp/value pairs.
* @summary Inspect raw metric data points
*/
export const inspectMetrics = (
metricsexplorertypesInspectMetricsRequestDTO: BodyType<MetricsexplorertypesInspectMetricsRequestDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<InspectMetrics200>({
url: `/api/v2/metrics/inspect`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: metricsexplorertypesInspectMetricsRequestDTO,
signal,
});
};
export const getInspectMetricsMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof inspectMetrics>>,
TError,
{ data: BodyType<MetricsexplorertypesInspectMetricsRequestDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof inspectMetrics>>,
TError,
{ data: BodyType<MetricsexplorertypesInspectMetricsRequestDTO> },
TContext
> => {
const mutationKey = ['inspectMetrics'];
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 inspectMetrics>>,
{ data: BodyType<MetricsexplorertypesInspectMetricsRequestDTO> }
> = (props) => {
const { data } = props ?? {};
return inspectMetrics(data);
};
return { mutationFn, ...mutationOptions };
};
export type InspectMetricsMutationResult = NonNullable<
Awaited<ReturnType<typeof inspectMetrics>>
>;
export type InspectMetricsMutationBody = BodyType<MetricsexplorertypesInspectMetricsRequestDTO>;
export type InspectMetricsMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Inspect raw metric data points
*/
export const useInspectMetrics = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof inspectMetrics>>,
TError,
{ data: BodyType<MetricsexplorertypesInspectMetricsRequestDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof inspectMetrics>>,
TError,
{ data: BodyType<MetricsexplorertypesInspectMetricsRequestDTO> },
TContext
> => {
const mutationOptions = getInspectMetricsMutationOptions(options);
return useMutation(mutationOptions);
};
/**
* Lightweight endpoint that checks if any non-SigNoz metrics have been ingested, used for onboarding status detection
* @summary Check if non-SigNoz metrics have been received
*/
export const getMetricsOnboardingStatus = (signal?: AbortSignal) => {
return GeneratedAPIInstance<GetMetricsOnboardingStatus200>({
url: `/api/v2/metrics/onboarding`,
method: 'GET',
signal,
});
};
export const getGetMetricsOnboardingStatusQueryKey = () => {
return [`/api/v2/metrics/onboarding`] as const;
};
export const getGetMetricsOnboardingStatusQueryOptions = <
TData = Awaited<ReturnType<typeof getMetricsOnboardingStatus>>,
TError = ErrorType<RenderErrorResponseDTO>
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricsOnboardingStatus>>,
TError,
TData
>;
}) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetMetricsOnboardingStatusQueryKey();
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getMetricsOnboardingStatus>>
> = ({ signal }) => getMetricsOnboardingStatus(signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof getMetricsOnboardingStatus>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetMetricsOnboardingStatusQueryResult = NonNullable<
Awaited<ReturnType<typeof getMetricsOnboardingStatus>>
>;
export type GetMetricsOnboardingStatusQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Check if non-SigNoz metrics have been received
*/
export function useGetMetricsOnboardingStatus<
TData = Awaited<ReturnType<typeof getMetricsOnboardingStatus>>,
TError = ErrorType<RenderErrorResponseDTO>
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricsOnboardingStatus>>,
TError,
TData
>;
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetMetricsOnboardingStatusQueryOptions(options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
query.queryKey = queryOptions.queryKey;
return query;
}
/**
* @summary Check if non-SigNoz metrics have been received
*/
export const invalidateGetMetricsOnboardingStatus = async (
queryClient: QueryClient,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetMetricsOnboardingStatusQueryKey() },
options,
);
return queryClient;
};
/**
* This endpoint provides list of metrics with their number of samples and timeseries for the given time range
* @summary Get metrics statistics

View File

@@ -1363,6 +1363,32 @@ export interface GlobaltypesTokenizerConfigDTO {
enabled?: boolean;
}
export interface MetricsexplorertypesInspectMetricsRequestDTO {
/**
* @type integer
* @format int64
*/
end: number;
filter?: Querybuildertypesv5FilterDTO;
/**
* @type string
*/
metricName: string;
/**
* @type integer
* @format int64
*/
start: number;
}
export interface MetricsexplorertypesInspectMetricsResponseDTO {
/**
* @type array
* @nullable true
*/
series: Querybuildertypesv5TimeSeriesDTO[] | null;
}
export interface MetricsexplorertypesListMetricDTO {
/**
* @type string
@@ -1508,6 +1534,13 @@ export interface MetricsexplorertypesMetricMetadataDTO {
unit: string;
}
export interface MetricsexplorertypesMetricsOnboardingResponseDTO {
/**
* @type boolean
*/
hasMetrics: boolean;
}
export interface MetricsexplorertypesStatDTO {
/**
* @type string
@@ -4391,6 +4424,22 @@ export type GetMetricMetadata200 = {
export type UpdateMetricMetadataPathParameters = {
metricName: string;
};
export type InspectMetrics200 = {
data: MetricsexplorertypesInspectMetricsResponseDTO;
/**
* @type string
*/
status: string;
};
export type GetMetricsOnboardingStatus200 = {
data: MetricsexplorertypesMetricsOnboardingResponseDTO;
/**
* @type string
*/
status: string;
};
export type GetMetricsStats200 = {
data: MetricsexplorertypesStatsResponseDTO;
/**

View File

@@ -1,54 +0,0 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
export interface InspectMetricsRequest {
metricName: string;
start: number;
end: number;
filters: TagFilter;
}
export interface InspectMetricsResponse {
status: string;
data: {
series: InspectMetricsSeries[];
};
}
export interface InspectMetricsSeries {
title?: string;
strokeColor?: string;
labels: Record<string, string>;
labelsArray: Array<Record<string, string>>;
values: InspectMetricsTimestampValue[];
}
interface InspectMetricsTimestampValue {
timestamp: number;
value: string;
}
export const getInspectMetricsDetails = async (
request: InspectMetricsRequest,
signal?: AbortSignal,
headers?: Record<string, string>,
): Promise<SuccessResponse<InspectMetricsResponse> | ErrorResponse> => {
try {
const response = await axios.post(`/metrics/inspect`, request, {
signal,
headers,
});
return {
statusCode: 200,
error: null,
message: 'Success',
payload: response.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};

View File

@@ -1,75 +0,0 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { MetricType } from './getMetricsList';
export interface MetricDetails {
name: string;
description: string;
type: string;
unit: string;
timeseries: number;
samples: number;
timeSeriesTotal: number;
timeSeriesActive: number;
lastReceived: string;
attributes: MetricDetailsAttribute[] | null;
metadata?: {
metric_type: MetricType;
description: string;
unit: string;
temporality?: Temporality;
};
alerts: MetricDetailsAlert[] | null;
dashboards: MetricDetailsDashboard[] | null;
}
export enum Temporality {
CUMULATIVE = 'Cumulative',
DELTA = 'Delta',
}
export interface MetricDetailsAttribute {
key: string;
value: string[];
valueCount: number;
}
export interface MetricDetailsAlert {
alert_name: string;
alert_id: string;
}
export interface MetricDetailsDashboard {
dashboard_name: string;
dashboard_id: string;
}
export interface MetricDetailsResponse {
status: string;
data: MetricDetails;
}
export const getMetricDetails = async (
metricName: string,
signal?: AbortSignal,
headers?: Record<string, string>,
): Promise<SuccessResponse<MetricDetailsResponse> | ErrorResponse> => {
try {
const response = await axios.get(`/metrics/${metricName}/metadata`, {
signal,
headers,
});
return {
statusCode: 200,
error: null,
message: 'Success',
payload: response.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};

View File

@@ -1,67 +0,0 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import {
OrderByPayload,
TreemapViewType,
} from 'container/MetricsExplorer/Summary/types';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
export interface MetricsListPayload {
filters: TagFilter;
groupBy?: BaseAutocompleteData[];
offset?: number;
limit?: number;
orderBy?: OrderByPayload;
}
export enum MetricType {
SUM = 'Sum',
GAUGE = 'Gauge',
HISTOGRAM = 'Histogram',
SUMMARY = 'Summary',
EXPONENTIAL_HISTOGRAM = 'ExponentialHistogram',
}
export interface MetricsListItemData {
metric_name: string;
description: string;
type: MetricType;
unit: string;
[TreemapViewType.TIMESERIES]: number;
[TreemapViewType.SAMPLES]: number;
lastReceived: string;
}
export interface MetricsListResponse {
status: string;
data: {
metrics: MetricsListItemData[];
total?: number;
};
}
export const getMetricsList = async (
props: MetricsListPayload,
signal?: AbortSignal,
headers?: Record<string, string>,
): Promise<SuccessResponse<MetricsListResponse> | ErrorResponse> => {
try {
const response = await axios.post('/metrics', props, {
signal,
headers,
});
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data,
params: props,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};

View File

@@ -1,44 +0,0 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
export interface MetricsListFilterKeysResponse {
status: string;
data: {
metricColumns: string[];
attributeKeys: BaseAutocompleteData[];
};
}
export interface GetMetricsListFilterKeysParams {
searchText: string;
limit?: number;
}
export const getMetricsListFilterKeys = async (
params: GetMetricsListFilterKeysParams,
signal?: AbortSignal,
headers?: Record<string, string>,
): Promise<SuccessResponse<MetricsListFilterKeysResponse> | ErrorResponse> => {
try {
const response = await axios.get('/metrics/filters/keys', {
params: {
searchText: params.searchText,
limit: params.limit,
},
signal,
headers,
});
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};

View File

@@ -1,43 +0,0 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
export interface MetricsListFilterValuesPayload {
filterAttributeKeyDataType: string;
filterKey: string;
searchText: string;
limit: number;
}
export interface MetricsListFilterValuesResponse {
status: string;
data: {
filterValues: string[];
};
}
export const getMetricsListFilterValues = async (
props: MetricsListFilterValuesPayload,
signal?: AbortSignal,
headers?: Record<string, string>,
): Promise<
SuccessResponse<MetricsListFilterValuesResponse> | ErrorResponse
> => {
try {
const response = await axios.post('/metrics/filters/values', props, {
signal,
headers,
});
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data,
params: props,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};

View File

@@ -1,60 +0,0 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
export interface RelatedMetricsPayload {
start: number;
end: number;
currentMetricName: string;
}
export interface RelatedMetricDashboard {
dashboard_name: string;
dashboard_id: string;
widget_id: string;
widget_name: string;
}
export interface RelatedMetricAlert {
alert_name: string;
alert_id: string;
}
export interface RelatedMetric {
name: string;
query: IBuilderQuery;
dashboards: RelatedMetricDashboard[];
alerts: RelatedMetricAlert[];
}
export interface RelatedMetricsResponse {
status: 'success';
data: {
related_metrics: RelatedMetric[];
};
}
export const getRelatedMetrics = async (
props: RelatedMetricsPayload,
signal?: AbortSignal,
headers?: Record<string, string>,
): Promise<SuccessResponse<RelatedMetricsResponse> | ErrorResponse> => {
try {
const response = await axios.post('/metrics/related', props, {
signal,
headers,
});
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data,
params: props,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};

View File

@@ -1,10 +1,11 @@
/* eslint-disable sonarjs/no-duplicate-string */
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import { useMutation, useQuery } from 'react-query';
import { Color } from '@signozhq/design-tokens';
import { Compass, Dot, House, Plus, Wrench } from '@signozhq/icons';
import { Button, Popover } from 'antd';
import logEvent from 'api/common/logEvent';
import { useGetMetricsOnboardingStatus } from 'api/generated/services/metrics';
import listUserPreferences from 'api/v1/user/preferences/list';
import updateUserPreferenceAPI from 'api/v1/user/preferences/name/update';
import { PersistedAnnouncementBanner } from 'components/AnnouncementBanner';
@@ -15,10 +16,8 @@ import { ORG_PREFERENCES } from 'constants/orgPreferences';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import ROUTES from 'constants/routes';
import { getMetricsListQuery } from 'container/MetricsExplorer/Summary/utils';
import { IS_SERVICE_ACCOUNTS_ENABLED } from 'container/ServiceAccountsSettings/config';
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
import { useGetMetricsList } from 'hooks/metricsExplorer/useGetMetricsList';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
@@ -127,38 +126,7 @@ export default function Home(): JSX.Element {
);
// Detect Metrics
const query = useMemo(() => {
const baseQuery = getMetricsListQuery();
let queryStartTime = startTime;
let queryEndTime = endTime;
if (!startTime || !endTime) {
const now = new Date();
const startTime = new Date(now.getTime() - homeInterval);
const endTime = now;
queryStartTime = startTime.getTime();
queryEndTime = endTime.getTime();
}
return {
...baseQuery,
limit: 10,
offset: 0,
filters: {
items: [],
op: 'AND',
},
start: queryStartTime,
end: queryEndTime,
};
}, [startTime, endTime]);
const { data: metricsData } = useGetMetricsList(query, {
enabled: !!query,
queryKey: ['metricsList', query],
});
const { data: metricsOnboardingData } = useGetMetricsOnboardingStatus();
const [isLogsIngestionActive, setIsLogsIngestionActive] = useState(false);
const [isTracesIngestionActive, setIsTracesIngestionActive] = useState(false);
@@ -284,14 +252,12 @@ export default function Home(): JSX.Element {
}, [tracesData, handleUpdateChecklistDoneItem]);
useEffect(() => {
const metricsDataTotal = metricsData?.payload?.data?.total ?? 0;
if (metricsDataTotal > 0) {
if (metricsOnboardingData?.data?.hasMetrics) {
setIsMetricsIngestionActive(true);
handleUpdateChecklistDoneItem('ADD_DATA_SOURCE');
handleUpdateChecklistDoneItem('SEND_METRICS');
}
}, [metricsData, handleUpdateChecklistDoneItem]);
}, [metricsOnboardingData, handleUpdateChecklistDoneItem]);
useEffect(() => {
logEvent('Homepage: Visited', {});

View File

@@ -48,6 +48,8 @@ import {
formatDataForTable,
getK8sVolumesListColumns,
getK8sVolumesListQuery,
getVolumeListGroupedByRowDataQueryKey,
getVolumesListQueryKey,
K8sVolumesRowData,
} from './utils';
import VolumeDetails from './VolumeDetails';
@@ -167,6 +169,26 @@ function K8sVolumesList({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [minTime, maxTime, orderBy, selectedRowData, groupBy]);
const groupedByRowDataQueryKey = useMemo(
() =>
getVolumeListGroupedByRowDataQueryKey(
selectedRowData?.groupedByMeta,
queryFilters,
orderBy,
groupBy,
minTime,
maxTime,
),
[
selectedRowData?.groupedByMeta,
queryFilters,
orderBy,
groupBy,
minTime,
maxTime,
],
);
const {
data: groupedByRowData,
isFetching: isFetchingGroupedByRowData,
@@ -176,7 +198,7 @@ function K8sVolumesList({
} = useGetK8sVolumesList(
fetchGroupedByRowDataQuery as K8sVolumesListPayload,
{
queryKey: ['volumeList', fetchGroupedByRowDataQuery],
queryKey: groupedByRowDataQueryKey,
enabled: !!fetchGroupedByRowDataQuery && !!selectedRowData,
},
undefined,
@@ -221,6 +243,28 @@ function K8sVolumesList({
return queryPayload;
}, [pageSize, currentPage, queryFilters, minTime, maxTime, orderBy, groupBy]);
const volumesListQueryKey = useMemo(() => {
return getVolumesListQueryKey(
selectedVolumeUID,
pageSize,
currentPage,
queryFilters,
orderBy,
groupBy,
minTime,
maxTime,
);
}, [
selectedVolumeUID,
pageSize,
currentPage,
queryFilters,
groupBy,
orderBy,
minTime,
maxTime,
]);
const formattedGroupedByVolumesData = useMemo(
() =>
formatDataForTable(groupedByRowData?.payload?.data?.records || [], groupBy),
@@ -237,7 +281,7 @@ function K8sVolumesList({
const { data, isFetching, isLoading, isError } = useGetK8sVolumesList(
query as K8sVolumesListPayload,
{
queryKey: ['volumeList', query],
queryKey: volumesListQueryKey,
enabled: !!query,
},
undefined,

View File

@@ -77,6 +77,74 @@ export const getK8sVolumesListQuery = (): K8sVolumesListPayload => ({
orderBy: { columnName: 'usage', order: 'desc' },
});
export const getVolumeListGroupedByRowDataQueryKey = (
groupedByMeta: K8sVolumesData['meta'] | undefined,
queryFilters: IBuilderQuery['filters'],
orderBy: { columnName: string; order: 'asc' | 'desc' } | null,
groupBy: IBuilderQuery['groupBy'],
minTime: number,
maxTime: number,
): (string | undefined)[] => {
// When we have grouped by metadata defined
// We need to leave out the min/max time
// Otherwise it will cause a loop
const groupedByMetaStr = JSON.stringify(groupedByMeta || undefined) ?? '';
if (groupedByMetaStr) {
return [
'volumeList',
JSON.stringify(queryFilters),
JSON.stringify(orderBy),
JSON.stringify(groupBy),
groupedByMetaStr,
];
}
return [
'volumeList',
JSON.stringify(queryFilters),
JSON.stringify(orderBy),
JSON.stringify(groupBy),
groupedByMetaStr,
String(minTime),
String(maxTime),
];
};
export const getVolumesListQueryKey = (
selectedVolumeUID: string | null,
pageSize: number,
currentPage: number,
queryFilters: IBuilderQuery['filters'],
orderBy: { columnName: string; order: 'asc' | 'desc' } | null,
groupBy: IBuilderQuery['groupBy'],
minTime: number,
maxTime: number,
): (string | undefined)[] => {
// When selected volume is defined
// We need to leave out the min/max time
// Otherwise it will cause a loop
if (selectedVolumeUID) {
return [
'volumeList',
String(pageSize),
String(currentPage),
JSON.stringify(queryFilters),
JSON.stringify(orderBy),
JSON.stringify(groupBy),
];
}
return [
'volumeList',
String(pageSize),
String(currentPage),
JSON.stringify(queryFilters),
JSON.stringify(orderBy),
JSON.stringify(groupBy),
String(minTime),
String(maxTime),
];
};
const columnsConfig = [
{
title: <div className="column-header-left pvc-name-header">PVC Name</div>,

View File

@@ -1,29 +1,93 @@
import setupCommonMocks from '../commonMocks';
setupCommonMocks();
import { QueryClient, QueryClientProvider } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { MemoryRouter } from 'react-router-dom';
import { render, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom-v5-compat';
import { FeatureKeys } from 'constants/features';
import K8sVolumesList from 'container/InfraMonitoringK8s/Volumes/K8sVolumesList';
import { rest, server } from 'mocks-server/server';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { IAppContext, IUser } from 'providers/App/types';
import { QueryBuilderProvider } from 'providers/QueryBuilder';
// eslint-disable-next-line no-restricted-imports
import { applyMiddleware, legacy_createStore as createStore } from 'redux';
import thunk from 'redux-thunk';
import reducers from 'store/reducers';
import { act, render, screen, userEvent, waitFor } from 'tests/test-utils';
import { UPDATE_TIME_INTERVAL } from 'types/actions/globalTime';
import { LicenseResModel } from 'types/api/licensesV3/getActive';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
cacheTime: 0,
},
},
});
import { INFRA_MONITORING_K8S_PARAMS_KEYS } from '../../constants';
const SERVER_URL = 'http://localhost/api';
// jsdom does not implement IntersectionObserver — provide a no-op stub
const mockObserver = {
observe: jest.fn(),
unobserve: jest.fn(),
disconnect: jest.fn(),
};
global.IntersectionObserver = jest
.fn()
.mockImplementation(() => mockObserver) as any;
const mockVolume = {
persistentVolumeClaimName: 'test-pvc',
volumeAvailable: 1000000,
volumeCapacity: 5000000,
volumeInodes: 100,
volumeInodesFree: 50,
volumeInodesUsed: 50,
volumeUsage: 4000000,
meta: {
k8s_cluster_name: 'test-cluster',
k8s_namespace_name: 'test-namespace',
k8s_node_name: 'test-node',
k8s_persistentvolumeclaim_name: 'test-pvc',
k8s_pod_name: 'test-pod',
k8s_pod_uid: 'test-pod-uid',
k8s_statefulset_name: '',
},
};
const mockVolumesResponse = {
status: 'success',
data: {
type: '',
records: [mockVolume],
groups: null,
total: 1,
sentAnyHostMetricsData: false,
isSendingK8SAgentMetrics: false,
},
};
/** Renders K8sVolumesList with a real Redux store so dispatched actions affect state. */
function renderWithRealStore(
initialEntries?: Record<string, any>,
): { testStore: ReturnType<typeof createStore> } {
const testStore = createStore(reducers, applyMiddleware(thunk as any));
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
render(
<NuqsTestingAdapter searchParams={initialEntries}>
<QueryClientProvider client={queryClient}>
<QueryBuilderProvider>
<MemoryRouter>
<K8sVolumesList
isFiltersVisible={false}
handleFilterVisibilityChange={jest.fn()}
quickFiltersLastUpdated={-1}
/>
</MemoryRouter>
</QueryBuilderProvider>
</QueryClientProvider>
</NuqsTestingAdapter>,
);
return { testStore };
}
describe('K8sVolumesList - useGetAggregateKeys Category Regression', () => {
let requestsMade: Array<{
url: string;
@@ -33,7 +97,6 @@ describe('K8sVolumesList - useGetAggregateKeys Category Regression', () => {
beforeEach(() => {
requestsMade = [];
queryClient.clear();
server.use(
rest.get(`${SERVER_URL}/v3/autocomplete/attribute_keys`, (req, res, ctx) => {
@@ -79,19 +142,7 @@ describe('K8sVolumesList - useGetAggregateKeys Category Regression', () => {
});
it('should call aggregate keys API with k8s_volume_capacity', async () => {
render(
<NuqsTestingAdapter>
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<K8sVolumesList
isFiltersVisible={false}
handleFilterVisibilityChange={jest.fn()}
quickFiltersLastUpdated={-1}
/>
</MemoryRouter>
</QueryClientProvider>
</NuqsTestingAdapter>,
);
renderWithRealStore();
await waitFor(() => {
expect(requestsMade.length).toBeGreaterThan(0);
@@ -128,19 +179,7 @@ describe('K8sVolumesList - useGetAggregateKeys Category Regression', () => {
activeLicense: (null as unknown) as LicenseResModel,
} as IAppContext);
render(
<NuqsTestingAdapter>
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<K8sVolumesList
isFiltersVisible={false}
handleFilterVisibilityChange={jest.fn()}
quickFiltersLastUpdated={-1}
/>
</MemoryRouter>
</QueryClientProvider>
</NuqsTestingAdapter>,
);
renderWithRealStore();
await waitFor(() => {
expect(requestsMade.length).toBeGreaterThan(0);
@@ -159,3 +198,193 @@ describe('K8sVolumesList - useGetAggregateKeys Category Regression', () => {
expect(aggregateAttribute).toBe('k8s.volume.capacity');
});
});
describe('K8sVolumesList', () => {
beforeEach(() => {
server.use(
rest.post('http://localhost/api/v1/pvcs/list', (_req, res, ctx) =>
res(ctx.status(200), ctx.json(mockVolumesResponse)),
),
rest.get(
'http://localhost/api/v3/autocomplete/attribute_keys',
(_req, res, ctx) =>
res(ctx.status(200), ctx.json({ data: { attributeKeys: [] } })),
),
);
});
it('renders volume rows from API response', async () => {
renderWithRealStore();
await waitFor(async () => {
const elements = await screen.findAllByText('test-pvc');
expect(elements.length).toBeGreaterThan(0);
});
});
it('opens VolumeDetails when a volume row is clicked', async () => {
const user = userEvent.setup();
renderWithRealStore();
const pvcCells = await screen.findAllByText('test-pvc');
expect(pvcCells.length).toBeGreaterThan(0);
const row = pvcCells[0].closest('tr');
expect(row).not.toBeNull();
await user.click(row!);
await waitFor(async () => {
const cells = await screen.findAllByText('test-pvc');
expect(cells.length).toBeGreaterThan(1);
});
});
it.skip('closes VolumeDetails when the close button is clicked', async () => {
const user = userEvent.setup();
renderWithRealStore();
const pvcCells = await screen.findAllByText('test-pvc');
expect(pvcCells.length).toBeGreaterThan(0);
const row = pvcCells[0].closest('tr');
await user.click(row!);
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument();
});
await user.click(screen.getByRole('button', { name: 'Close' }));
await waitFor(() => {
expect(
screen.queryByRole('button', { name: 'Close' }),
).not.toBeInTheDocument();
});
});
it('does not re-fetch the volumes list when time range changes after selecting a volume', async () => {
const user = userEvent.setup();
let apiCallCount = 0;
server.use(
rest.post('http://localhost/api/v1/pvcs/list', async (_req, res, ctx) => {
apiCallCount += 1;
return res(ctx.status(200), ctx.json(mockVolumesResponse));
}),
);
const { testStore } = renderWithRealStore();
await waitFor(() => expect(apiCallCount).toBe(1));
const pvcCells = await screen.findAllByText('test-pvc');
const row = pvcCells[0].closest('tr');
await user.click(row!);
await waitFor(async () => {
const cells = await screen.findAllByText('test-pvc');
expect(cells.length).toBeGreaterThan(1);
});
// Wait for nuqs URL state to fully propagate to the component
// The selectedVolumeUID is managed via nuqs (async URL state),
// so we need to ensure the state has settled before dispatching time changes
await act(async () => {
await new Promise((resolve) => {
setTimeout(resolve, 0);
});
});
const countAfterClick = apiCallCount;
// There's a specific component causing the min/max time to be updated
// After the volume loads, it triggers the change again
// And then the query to fetch data for the selected volume enters in a loop
act(() => {
testStore.dispatch({
type: UPDATE_TIME_INTERVAL,
payload: {
minTime: Date.now() * 1000000 - 30 * 60 * 1000 * 1000000,
maxTime: Date.now() * 1000000,
selectedTime: '30m',
},
} as any);
});
// Allow any potential re-fetch to settle
await new Promise((resolve) => {
setTimeout(resolve, 500);
});
expect(apiCallCount).toBe(countAfterClick);
});
it('does not re-fetch groupedByRowData when time range changes after expanding a volume row with groupBy', async () => {
const user = userEvent.setup();
const groupByValue = [{ key: 'k8s_namespace_name' }];
let groupedByRowDataCallCount = 0;
server.use(
rest.post('http://localhost/api/v1/pvcs/list', async (req, res, ctx) => {
const body = await req.json();
// Check for both underscore and dot notation keys since dotMetricsEnabled
// may be true or false depending on test order
const isGroupedByRowDataRequest = body.filters?.items?.some(
(item: { key?: { key?: string }; value?: string }) =>
(item.key?.key === 'k8s_namespace_name' ||
item.key?.key === 'k8s.namespace.name') &&
item.value === 'test-namespace',
);
if (isGroupedByRowDataRequest) {
groupedByRowDataCallCount += 1;
}
return res(ctx.status(200), ctx.json(mockVolumesResponse));
}),
rest.get(
'http://localhost/api/v3/autocomplete/attribute_keys',
(_req, res, ctx) =>
res(
ctx.status(200),
ctx.json({
data: {
attributeKeys: [{ key: 'k8s_namespace_name', dataType: 'string' }],
},
}),
),
),
);
const { testStore } = renderWithRealStore({
[INFRA_MONITORING_K8S_PARAMS_KEYS.GROUP_BY]: JSON.stringify(groupByValue),
});
await waitFor(async () => {
const elements = await screen.findAllByText('test-namespace');
return expect(elements.length).toBeGreaterThan(0);
});
const row = (await screen.findAllByText('test-namespace'))[0].closest('tr');
expect(row).not.toBeNull();
user.click(row as HTMLElement);
await waitFor(() => expect(groupedByRowDataCallCount).toBe(1));
const countAfterExpand = groupedByRowDataCallCount;
act(() => {
testStore.dispatch({
type: UPDATE_TIME_INTERVAL,
payload: {
minTime: Date.now() * 1000000 - 30 * 60 * 1000 * 1000000,
maxTime: Date.now() * 1000000,
selectedTime: '30m',
},
} as any);
});
// Allow any potential re-fetch to settle
await new Promise((resolve) => {
setTimeout(resolve, 500);
});
expect(groupedByRowDataCallCount).toBe(countAfterExpand);
});
});

View File

@@ -1,37 +0,0 @@
import { UseQueryResult } from 'react-query';
import { RelatedMetric } from 'api/metricsExplorer/getRelatedMetrics';
import { SuccessResponse } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
export enum ExplorerTabs {
TIME_SERIES = 'time-series',
RELATED_METRICS = 'related-metrics',
}
export interface TimeSeriesProps {
showOneChartPerQuery: boolean;
}
export interface RelatedMetricsProps {
metricNames: string[];
}
export interface RelatedMetricsCardProps {
metric: RelatedMetricWithQueryResult;
}
export interface UseGetRelatedMetricsGraphsProps {
selectedMetricName: string | null;
startMs: number;
endMs: number;
}
export interface UseGetRelatedMetricsGraphsReturn {
relatedMetrics: RelatedMetricWithQueryResult[];
isRelatedMetricsLoading: boolean;
isRelatedMetricsError: boolean;
}
export interface RelatedMetricWithQueryResult extends RelatedMetric {
queryResult: UseQueryResult<SuccessResponse<MetricRangePayloadProps>, unknown>;
}

View File

@@ -30,31 +30,6 @@
}
}
.explore-tabs {
margin: 15px 0;
.tab {
background-color: var(--bg-slate-500);
border-color: var(--bg-ink-200);
width: 180px;
padding: 16px 0;
display: flex;
justify-content: center;
align-items: center;
}
.tab:first-of-type {
border-top-left-radius: 2px;
}
.tab:last-of-type {
border-top-right-radius: 2px;
}
.selected-view {
background: var(--bg-ink-500);
}
}
.explore-content {
padding: 0 8px;
@@ -116,81 +91,6 @@
width: 100%;
height: fit-content;
}
.related-metrics-container {
width: 100%;
min-height: 300px;
display: flex;
flex-direction: column;
gap: 10px;
.related-metrics-header {
display: flex;
align-items: center;
justify-content: flex-start;
.metric-name-select {
width: 20%;
margin-right: 10px;
}
.related-metrics-input {
width: 40%;
.ant-input-wrapper {
.ant-input-group-addon {
.related-metrics-select {
width: 250px;
border: 1px solid var(--bg-slate-500) !important;
.ant-select-selector {
text-align: left;
color: var(--text-vanilla-500) !important;
}
}
}
}
}
}
.related-metrics-body {
margin-top: 20px;
max-height: 650px;
overflow-y: scroll;
.related-metrics-card-container {
margin-bottom: 20px;
min-height: 640px;
.related-metrics-card {
display: flex;
flex-direction: column;
gap: 16px;
.related-metrics-card-error {
padding-top: 10px;
height: fit-content;
width: fit-content;
}
}
}
}
}
}
}
.lightMode {
.metrics-explorer-explore-container {
.explore-tabs {
.tab {
background-color: var(--bg-vanilla-100);
border-color: var(--bg-vanilla-400);
}
.selected-view {
background: var(--bg-vanilla-500);
}
}
}
}

View File

@@ -32,7 +32,6 @@ import { v4 as uuid } from 'uuid';
import { MetricsExplorerEventKeys, MetricsExplorerEvents } from '../events';
import MetricDetails from '../MetricDetails/MetricDetails';
import TimeSeries from './TimeSeries';
import { ExplorerTabs } from './types';
import {
getMetricUnits,
splitQueryIntoOneChartPerQuery,
@@ -95,7 +94,6 @@ function Explorer(): JSX.Element {
const [disableOneChartPerQuery, toggleDisableOneChartPerQuery] = useState(
false,
);
const [selectedTab] = useState<ExplorerTabs>(ExplorerTabs.TIME_SERIES);
const [yAxisUnit, setYAxisUnit] = useState<string | undefined>();
const unitsLength = useMemo(() => units.length, [units]);
@@ -319,48 +317,21 @@ function Explorer(): JSX.Element {
showFunctions={false}
version="v3"
/>
{/* TODO: Enable once we have resolved all related metrics issues */}
{/* <Button.Group className="explore-tabs">
<Button
value={ExplorerTabs.TIME_SERIES}
className={classNames('tab', {
'selected-view': selectedTab === ExplorerTabs.TIME_SERIES,
})}
onClick={(): void => setSelectedTab(ExplorerTabs.TIME_SERIES)}
>
<Typography.Text>Time series</Typography.Text>
</Button>
<Button
value={ExplorerTabs.RELATED_METRICS}
className={classNames('tab', {
'selected-view': selectedTab === ExplorerTabs.RELATED_METRICS,
})}
onClick={(): void => setSelectedTab(ExplorerTabs.RELATED_METRICS)}
>
<Typography.Text>Related</Typography.Text>
</Button>
</Button.Group> */}
<div className="explore-content">
{selectedTab === ExplorerTabs.TIME_SERIES && (
<TimeSeries
showOneChartPerQuery={showOneChartPerQuery}
setWarning={setWarning}
areAllMetricUnitsSame={areAllMetricUnitsSame}
isMetricUnitsLoading={isMetricUnitsLoading}
isMetricUnitsError={isMetricUnitsError}
metricUnits={units}
metricNames={metricNames}
metrics={metrics}
handleOpenMetricDetails={handleOpenMetricDetails}
yAxisUnit={yAxisUnit}
setYAxisUnit={setYAxisUnit}
showYAxisUnitSelector={showYAxisUnitSelector}
/>
)}
{/* TODO: Enable once we have resolved all related metrics issues */}
{/* {selectedTab === ExplorerTabs.RELATED_METRICS && (
<RelatedMetrics metricNames={metricNames} />
)} */}
<TimeSeries
showOneChartPerQuery={showOneChartPerQuery}
setWarning={setWarning}
areAllMetricUnitsSame={areAllMetricUnitsSame}
isMetricUnitsLoading={isMetricUnitsLoading}
isMetricUnitsError={isMetricUnitsError}
metricUnits={units}
metricNames={metricNames}
metrics={metrics}
handleOpenMetricDetails={handleOpenMetricDetails}
yAxisUnit={yAxisUnit}
setYAxisUnit={setYAxisUnit}
showYAxisUnitSelector={showYAxisUnitSelector}
/>
</div>
</div>
<ExplorerOptionWrapper

View File

@@ -1,153 +0,0 @@
import { useEffect, useMemo, useRef, useState } from 'react';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { Color } from '@signozhq/design-tokens';
import { Card, Col, Empty, Input, Row, Select, Skeleton } from 'antd';
import { Gauge } from 'lucide-react';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
import RelatedMetricsCard from './RelatedMetricsCard';
import { RelatedMetricsProps, RelatedMetricWithQueryResult } from './types';
import { useGetRelatedMetricsGraphs } from './useGetRelatedMetricsGraphs';
function RelatedMetrics({ metricNames }: RelatedMetricsProps): JSX.Element {
const graphRef = useRef<HTMLDivElement>(null);
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const [selectedMetricName, setSelectedMetricName] = useState<string | null>(
null,
);
const [selectedRelatedMetric, setSelectedRelatedMetric] = useState('all');
const [searchValue, setSearchValue] = useState<string | null>(null);
const startMs = useMemo(() => Math.floor(Number(minTime) / 1000000000), [
minTime,
]);
const endMs = useMemo(() => Math.floor(Number(maxTime) / 1000000000), [
maxTime,
]);
useEffect(() => {
if (metricNames.length) {
setSelectedMetricName(metricNames[0]);
}
}, [metricNames]);
const {
relatedMetrics,
isRelatedMetricsLoading,
isRelatedMetricsError,
} = useGetRelatedMetricsGraphs({
selectedMetricName,
startMs,
endMs,
});
const metricNamesSelectOptions = useMemo(
() =>
metricNames.map((name) => ({
value: name,
label: name,
})),
[metricNames],
);
const relatedMetricsSelectOptions = useMemo(() => {
const options: { value: string; label: string }[] = [
{
value: 'all',
label: 'All',
},
];
relatedMetrics.forEach((metric) => {
options.push({
value: metric.name,
label: metric.name,
});
});
return options;
}, [relatedMetrics]);
const filteredRelatedMetrics = useMemo(() => {
let filteredMetrics: RelatedMetricWithQueryResult[] = [];
if (selectedRelatedMetric === 'all') {
filteredMetrics = [...relatedMetrics];
} else {
filteredMetrics = relatedMetrics.filter(
(metric) => metric.name === selectedRelatedMetric,
);
}
if (searchValue?.length) {
filteredMetrics = filteredMetrics.filter((metric) =>
metric.name.toLowerCase().includes(searchValue?.toLowerCase() ?? ''),
);
}
return filteredMetrics;
}, [relatedMetrics, selectedRelatedMetric, searchValue]);
return (
<div className="related-metrics-container">
<div className="related-metrics-header">
<Select
className="metric-name-select"
value={selectedMetricName}
options={metricNamesSelectOptions}
onChange={(value): void => setSelectedMetricName(value)}
suffixIcon={<Gauge size={12} color={Color.BG_SAKURA_500} />}
/>
<Input
className="related-metrics-input"
placeholder="Search..."
onChange={(e): void => setSearchValue(e.target.value)}
bordered
addonBefore={
<Select
loading={isRelatedMetricsLoading}
value={selectedRelatedMetric}
className="related-metrics-select"
options={relatedMetricsSelectOptions}
onChange={(value): void => setSelectedRelatedMetric(value)}
bordered={false}
/>
}
/>
</div>
<div className="related-metrics-body">
{isRelatedMetricsLoading && <Skeleton active />}
{isRelatedMetricsError && (
<Empty description="Error fetching related metrics" />
)}
{!isRelatedMetricsLoading &&
!isRelatedMetricsError &&
filteredRelatedMetrics.length === 0 && (
<Empty description="No related metrics found" />
)}
{!isRelatedMetricsLoading &&
!isRelatedMetricsError &&
filteredRelatedMetrics.length > 0 && (
<Row gutter={24}>
{filteredRelatedMetrics.map((relatedMetricWithQueryResult) => (
<Col span={12} key={relatedMetricWithQueryResult.name}>
<Card
bordered
ref={graphRef}
className="related-metrics-card-container"
>
<RelatedMetricsCard
key={relatedMetricWithQueryResult.name}
metric={relatedMetricWithQueryResult}
/>
</Card>
</Col>
))}
</Row>
)}
</div>
</div>
);
}
export default RelatedMetrics;

View File

@@ -1,47 +0,0 @@
import { Empty, Skeleton, Typography } from 'antd';
import TimeSeriesView from 'container/TimeSeriesView/TimeSeriesView';
import { DataSource } from 'types/common/queryBuilder';
import DashboardsAndAlertsPopover from '../MetricDetails/DashboardsAndAlertsPopover';
import { RelatedMetricsCardProps } from './types';
function RelatedMetricsCard({ metric }: RelatedMetricsCardProps): JSX.Element {
const { queryResult } = metric;
if (queryResult.isLoading) {
return <Skeleton />;
}
if (queryResult.error) {
const errorMessage =
(queryResult.error as Error)?.message || 'Something went wrong';
return <div>{errorMessage}</div>;
}
return (
<div className="related-metrics-card">
<Typography.Text className="related-metrics-card-name">
{metric.name}
</Typography.Text>
{queryResult.isLoading ? <Skeleton /> : null}
{queryResult.isError ? (
<div className="related-metrics-card-error">
<Empty description="Error fetching metric data" />
</div>
) : null}
{!queryResult.isLoading && !queryResult.error && (
<TimeSeriesView
isFilterApplied={false}
isError={queryResult.isError}
isLoading={queryResult.isLoading}
data={queryResult.data}
yAxisUnit="ms"
dataSource={DataSource.METRICS}
/>
)}
<DashboardsAndAlertsPopover metricName={metric.name} />
</div>
);
}
export default RelatedMetricsCard;

View File

@@ -1,14 +1,6 @@
import { Dispatch, SetStateAction } from 'react';
import { UseQueryResult } from 'react-query';
import { MetricsexplorertypesMetricMetadataDTO } from 'api/generated/services/sigNoz.schemas';
import { RelatedMetric } from 'api/metricsExplorer/getRelatedMetrics';
import { SuccessResponse, Warning } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
export enum ExplorerTabs {
TIME_SERIES = 'time-series',
RELATED_METRICS = 'related-metrics',
}
import { Warning } from 'types/api';
export interface TimeSeriesProps {
showOneChartPerQuery: boolean;
@@ -24,27 +16,3 @@ export interface TimeSeriesProps {
setYAxisUnit: (unit: string) => void;
showYAxisUnitSelector: boolean;
}
export interface RelatedMetricsProps {
metricNames: string[];
}
export interface RelatedMetricsCardProps {
metric: RelatedMetricWithQueryResult;
}
export interface UseGetRelatedMetricsGraphsProps {
selectedMetricName: string | null;
startMs: number;
endMs: number;
}
export interface UseGetRelatedMetricsGraphsReturn {
relatedMetrics: RelatedMetricWithQueryResult[];
isRelatedMetricsLoading: boolean;
isRelatedMetricsError: boolean;
}
export interface RelatedMetricWithQueryResult extends RelatedMetric {
queryResult: UseQueryResult<SuccessResponse<MetricRangePayloadProps>, unknown>;
}

View File

@@ -1,113 +0,0 @@
import { useMemo } from 'react';
import { useQueries } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { ENTITY_VERSION_V4 } from 'constants/app';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useGetRelatedMetrics } from 'hooks/metricsExplorer/useGetRelatedMetrics';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { AppState } from 'store/reducers';
import { SuccessResponse } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import { v4 as uuidv4 } from 'uuid';
import { convertNanoToMilliseconds } from '../Summary/utils';
import {
UseGetRelatedMetricsGraphsProps,
UseGetRelatedMetricsGraphsReturn,
} from './types';
export const useGetRelatedMetricsGraphs = ({
selectedMetricName,
startMs,
endMs,
}: UseGetRelatedMetricsGraphsProps): UseGetRelatedMetricsGraphsReturn => {
const { maxTime, minTime, selectedTime: globalSelectedTime } = useSelector<
AppState,
GlobalReducer
>((state) => state.globalTime);
// Build the query for the related metrics
const relatedMetricsQuery = useMemo(
() => ({
start: convertNanoToMilliseconds(minTime),
end: convertNanoToMilliseconds(maxTime),
currentMetricName: selectedMetricName ?? '',
}),
[selectedMetricName, minTime, maxTime],
);
// Get the related metrics
const {
data: relatedMetricsData,
isLoading: isRelatedMetricsLoading,
isError: isRelatedMetricsError,
} = useGetRelatedMetrics(relatedMetricsQuery, {
enabled: !!selectedMetricName,
});
// Build the related metrics array
const relatedMetrics = useMemo(() => {
if (relatedMetricsData?.payload?.data?.related_metrics) {
return relatedMetricsData.payload.data.related_metrics;
}
return [];
}, [relatedMetricsData]);
// Build the query results for the related metrics
const relatedMetricsQueryResults = useQueries(
useMemo(
() =>
relatedMetrics.map((metric) => ({
queryKey: ['related-metrics', metric.name],
queryFn: (): Promise<SuccessResponse<MetricRangePayloadProps>> =>
GetMetricQueryRange(
{
query: {
queryType: EQueryType.QUERY_BUILDER,
promql: [],
builder: {
queryData: [metric.query],
queryFormulas: [],
queryTraceOperator: [],
},
clickhouse_sql: [],
id: uuidv4(),
},
graphType: PANEL_TYPES.TIME_SERIES,
selectedTime: 'GLOBAL_TIME',
globalSelectedInterval: globalSelectedTime,
start: startMs,
end: endMs,
formatForWeb: false,
params: {
dataSource: DataSource.METRICS,
},
},
ENTITY_VERSION_V4,
),
enabled: !!metric.query,
})),
[relatedMetrics, globalSelectedTime, startMs, endMs],
),
);
// Build the related metrics with query results
const relatedMetricsWithQueryResults = useMemo(
() =>
relatedMetrics.map((metric, index) => ({
...metric,
queryResult: relatedMetricsQueryResults[index],
})),
[relatedMetrics, relatedMetricsQueryResults],
);
return {
relatedMetrics: relatedMetricsWithQueryResults,
isRelatedMetricsLoading,
isRelatedMetricsError,
};
};

View File

@@ -4,7 +4,6 @@ import { Color } from '@signozhq/design-tokens';
import type { TableColumnsType as ColumnsType } from 'antd';
import { Card, Tooltip, Typography } from 'antd';
import logEvent from 'api/common/logEvent';
import { InspectMetricsSeries } from 'api/metricsExplorer/getInspectMetricsDetails';
import classNames from 'classnames';
import ResizeTable from 'components/ResizeTable/ResizeTable';
import { DataType } from 'container/LogDetailedView/TableView';
@@ -15,6 +14,7 @@ import {
SPACE_AGGREGATION_OPTIONS_FOR_EXPANDED_VIEW,
TIME_AGGREGATION_OPTIONS,
} from './constants';
import { InspectMetricsSeries } from './types';
import {
ExpandedViewProps,
InspectionStep,
@@ -42,7 +42,8 @@ function ExpandedView({
useEffect(() => {
logEvent(MetricsExplorerEvents.InspectPointClicked, {
[MetricsExplorerEventKeys.Modal]: 'inspect',
[MetricsExplorerEventKeys.Filters]: metricInspectionAppliedOptions.filters,
[MetricsExplorerEventKeys.Filters]:
metricInspectionAppliedOptions.filterExpression,
[MetricsExplorerEventKeys.TimeAggregationInterval]:
metricInspectionAppliedOptions.timeAggregationInterval,
[MetricsExplorerEventKeys.TimeAggregationOption]:

View File

@@ -3,7 +3,7 @@ import * as Sentry from '@sentry/react';
import { Color } from '@signozhq/design-tokens';
import { Button, Drawer, Empty, Skeleton, Typography } from 'antd';
import logEvent from 'api/common/logEvent';
import { useGetMetricDetails } from 'hooks/metricsExplorer/useGetMetricDetails';
import { useGetMetricMetadata } from 'api/generated/services/metrics';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import { useIsDarkMode } from 'hooks/useDarkMode';
@@ -48,10 +48,12 @@ function Inspect({
] = useState<GraphPopoverOptions | null>(null);
const [showExpandedView, setShowExpandedView] = useState(false);
const { data: metricDetailsData } = useGetMetricDetails(
appliedMetricName ?? '',
const { data: metricDetailsData } = useGetMetricMetadata(
{ metricName: appliedMetricName ?? '' },
{
enabled: !!appliedMetricName,
query: {
enabled: !!appliedMetricName,
},
},
);
@@ -93,7 +95,6 @@ function Inspect({
const {
inspectMetricsTimeSeries,
inspectMetricsStatusCode,
isInspectMetricsLoading,
isInspectMetricsError,
formattedInspectMetricsTimeSeries,
@@ -118,15 +119,13 @@ function Inspect({
[dispatchMetricInspectionOptions],
);
const selectedMetricType = useMemo(
() => metricDetailsData?.payload?.data?.metadata?.metric_type,
[metricDetailsData],
);
const selectedMetricType = useMemo(() => metricDetailsData?.data?.type, [
metricDetailsData,
]);
const selectedMetricUnit = useMemo(
() => metricDetailsData?.payload?.data?.metadata?.unit,
[metricDetailsData],
);
const selectedMetricUnit = useMemo(() => metricDetailsData?.data?.unit, [
metricDetailsData,
]);
const aggregateAttribute = useMemo(
() => ({
@@ -180,11 +179,8 @@ function Inspect({
);
}
if (isInspectMetricsError || inspectMetricsStatusCode !== 200) {
const errorMessage =
inspectMetricsStatusCode === 400
? 'The time range is too large. Please modify it to be within 30 minutes.'
: 'Error loading inspect metrics.';
if (isInspectMetricsError) {
const errorMessage = 'Error loading inspect metrics.';
return (
<div
@@ -261,7 +257,6 @@ function Inspect({
isInspectMetricsLoading,
isInspectMetricsRefetching,
isInspectMetricsError,
inspectMetricsStatusCode,
inspectMetricsTimeSeries,
aggregatedTimeSeries,
formattedInspectMetricsTimeSeries,

View File

@@ -33,7 +33,7 @@ function MetricFilters({
});
dispatchMetricInspectionOptions({
type: 'SET_FILTERS',
payload: tagFilter,
payload: expression,
});
},
[currentQuery, dispatchMetricInspectionOptions, setCurrentQuery],

View File

@@ -1,8 +1,8 @@
import { useCallback, useMemo } from 'react';
import type { TableColumnsType as ColumnsType } from 'antd';
import { Card, Flex, Table, Typography } from 'antd';
import { InspectMetricsSeries } from 'api/metricsExplorer/getInspectMetricsDetails';
import { InspectMetricsSeries } from './types';
import { TableViewProps } from './types';
import { formatTimestampToFullDateTime } from './utils';

View File

@@ -1,11 +1,11 @@
import { render, screen } from '@testing-library/react';
import { InspectMetricsSeries } from 'api/metricsExplorer/getInspectMetricsDetails';
import {
SPACE_AGGREGATION_OPTIONS_FOR_EXPANDED_VIEW,
TIME_AGGREGATION_OPTIONS,
} from '../constants';
import ExpandedView from '../ExpandedView';
import { InspectMetricsSeries } from '../types';
import {
GraphPopoverData,
InspectionStep,
@@ -25,7 +25,6 @@ describe('ExpandedView', () => {
labels: {
host_id: 'test-id',
},
labelsArray: [],
title: 'TS1',
};
@@ -66,10 +65,7 @@ describe('ExpandedView', () => {
timeAggregationInterval: 60,
spaceAggregationOption: SpaceAggregationOptions.MAX_BY,
spaceAggregationLabels: ['host_name'],
filters: {
items: [],
op: 'AND',
},
filterExpression: '',
};
it('renders entire time series for a raw data inspection', () => {

View File

@@ -1,7 +1,7 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { InspectMetricsSeries } from 'api/metricsExplorer/getInspectMetricsDetails';
import GraphPopover from '../GraphPopover';
import { InspectMetricsSeries } from '../types';
import { GraphPopoverOptions, InspectionStep } from '../types';
describe('GraphPopover', () => {
@@ -16,7 +16,6 @@ describe('GraphPopover', () => {
{ timestamp: 1672531260000, value: '43.456' },
],
labels: {},
labelsArray: [],
},
};
const mockSpaceAggregationSeriesMap: Map<

View File

@@ -2,12 +2,12 @@
import { Provider } from 'react-redux';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { InspectMetricsSeries } from 'api/metricsExplorer/getInspectMetricsDetails';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import { MetrictypesTypeDTO } from 'api/generated/services/sigNoz.schemas';
import store from 'store';
import { AlignedData } from 'uplot';
import GraphView from '../GraphView';
import { InspectMetricsSeries } from '../types';
import {
InspectionStep,
SpaceAggregationOptions,
@@ -32,7 +32,6 @@ describe('GraphView', () => {
{ timestamp: 1234567891000, value: '20' },
],
labels: { label1: 'value1' },
labelsArray: [{ label: 'label1', value: 'value1' }],
},
];
@@ -44,7 +43,7 @@ describe('GraphView', () => {
] as AlignedData,
metricUnit: '',
metricName: 'test_metric',
metricType: MetricType.GAUGE,
metricType: MetrictypesTypeDTO.gauge,
spaceAggregationSeriesMap: new Map(),
inspectionStep: InspectionStep.COMPLETED,
setPopoverOptions: jest.fn(),
@@ -58,10 +57,7 @@ describe('GraphView', () => {
spaceAggregationOption: SpaceAggregationOptions.MAX_BY,
spaceAggregationLabels: ['host_name'],
timeAggregationOption: TimeAggregationOptions.MAX,
filters: {
items: [],
op: 'AND',
},
filterExpression: '',
},
isInspectMetricsRefetching: false,
};

View File

@@ -2,17 +2,21 @@ import { QueryClient, QueryClientProvider } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { Provider } from 'react-redux';
import { render, screen } from '@testing-library/react';
import { InspectMetricsSeries } from 'api/metricsExplorer/getInspectMetricsDetails';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import * as useInspectMetricsHooks from 'hooks/metricsExplorer/useGetInspectMetricsDetails';
import * as useGetMetricDetailsHooks from 'hooks/metricsExplorer/useGetMetricDetails';
import * as metricsGeneratedAPI from 'api/generated/services/metrics';
import { MetrictypesTypeDTO } from 'api/generated/services/sigNoz.schemas';
import * as appContextHooks from 'providers/App/App';
import store from 'store';
import ROUTES from '../../../../constants/routes';
import { LicenseEvent } from '../../../../types/api/licensesV3/getActive';
import { INITIAL_INSPECT_METRICS_OPTIONS } from '../constants';
import Inspect from '../Inspect';
import { InspectionStep } from '../types';
import {
InspectionStep,
InspectMetricsSeries,
UseInspectMetricsReturnData,
} from '../types';
import * as useInspectMetricsModule from '../useInspectMetrics';
const queryClient = new QueryClient();
const mockTimeSeries: InspectMetricsSeries[] = [
@@ -24,7 +28,6 @@ const mockTimeSeries: InspectMetricsSeries[] = [
{ timestamp: 1234567891000, value: '20' },
],
labels: { label1: 'value1' },
labelsArray: [{ label: 'label1', value: 'value1' }],
},
];
@@ -52,29 +55,19 @@ jest.spyOn(appContextHooks, 'useAppContext').mockReturnValue({
},
} as any);
jest.spyOn(useGetMetricDetailsHooks, 'useGetMetricDetails').mockReturnValue({
jest.spyOn(metricsGeneratedAPI, 'useGetMetricMetadata').mockReturnValue({
data: {
metricDetails: {
metricName: 'test_metric',
metricType: MetricType.GAUGE,
data: {
type: MetrictypesTypeDTO.gauge,
unit: '',
description: '',
temporality: '',
isMonotonic: false,
},
status: 'success',
},
} as any);
jest
.spyOn(useInspectMetricsHooks, 'useGetInspectMetricsDetails')
.mockReturnValue({
data: {
payload: {
data: {
series: mockTimeSeries,
},
status: 'success',
},
},
isLoading: false,
} as any);
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: (): { pathname: string } => ({
@@ -90,16 +83,25 @@ mockResizeObserver.mockImplementation(() => ({
}));
window.ResizeObserver = mockResizeObserver;
const baseHookReturn: UseInspectMetricsReturnData = {
inspectMetricsTimeSeries: [],
isInspectMetricsLoading: false,
isInspectMetricsError: false,
formattedInspectMetricsTimeSeries: [[], []],
spaceAggregationLabels: [],
metricInspectionOptions: INITIAL_INSPECT_METRICS_OPTIONS,
dispatchMetricInspectionOptions: jest.fn(),
inspectionStep: InspectionStep.COMPLETED,
isInspectMetricsRefetching: false,
spaceAggregatedSeriesMap: new Map(),
aggregatedTimeSeries: [],
timeAggregatedSeriesMap: new Map(),
reset: jest.fn(),
};
describe('Inspect', () => {
const defaultProps = {
inspectMetricsTimeSeries: mockTimeSeries,
formattedInspectMetricsTimeSeries: [],
metricUnit: '',
metricName: 'test_metric',
metricType: MetricType.GAUGE,
spaceAggregationSeriesMap: new Map(),
inspectionStep: InspectionStep.COMPLETED,
resetInspection: jest.fn(),
isOpen: true,
onClose: jest.fn(),
};
@@ -109,6 +111,12 @@ describe('Inspect', () => {
});
it('renders all components', () => {
jest.spyOn(useInspectMetricsModule, 'useInspectMetrics').mockReturnValue({
...baseHookReturn,
inspectMetricsTimeSeries: mockTimeSeries,
aggregatedTimeSeries: mockTimeSeries,
});
render(
<QueryClientProvider client={queryClient}>
<Provider store={store}>
@@ -123,18 +131,11 @@ describe('Inspect', () => {
});
it('renders loading state', () => {
jest
.spyOn(useInspectMetricsHooks, 'useGetInspectMetricsDetails')
.mockReturnValue({
data: {
payload: {
data: {
series: [],
},
},
},
isLoading: true,
} as any);
jest.spyOn(useInspectMetricsModule, 'useInspectMetrics').mockReturnValue({
...baseHookReturn,
isInspectMetricsLoading: true,
});
render(
<QueryClientProvider client={queryClient}>
<Provider store={store}>
@@ -147,18 +148,11 @@ describe('Inspect', () => {
});
it('renders empty state', () => {
jest
.spyOn(useInspectMetricsHooks, 'useGetInspectMetricsDetails')
.mockReturnValue({
data: {
payload: {
data: {
series: [],
},
},
},
isLoading: false,
} as any);
jest.spyOn(useInspectMetricsModule, 'useInspectMetrics').mockReturnValue({
...baseHookReturn,
inspectMetricsTimeSeries: [],
});
render(
<QueryClientProvider client={queryClient}>
<Provider store={store}>
@@ -171,39 +165,11 @@ describe('Inspect', () => {
});
it('renders error state', () => {
jest
.spyOn(useInspectMetricsHooks, 'useGetInspectMetricsDetails')
.mockReturnValue({
data: {
payload: {
data: {
series: [],
},
},
},
isLoading: false,
isError: true,
} as any);
render(
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<Inspect {...defaultProps} />
</Provider>
</QueryClientProvider>,
);
jest.spyOn(useInspectMetricsModule, 'useInspectMetrics').mockReturnValue({
...baseHookReturn,
isInspectMetricsError: true,
});
expect(screen.getByTestId('inspect-metrics-error')).toBeInTheDocument();
});
it('renders error state with 400 status code', () => {
jest
.spyOn(useInspectMetricsHooks, 'useGetInspectMetricsDetails')
.mockReturnValue({
data: {
statusCode: 400,
},
isError: false,
} as any);
render(
<QueryClientProvider client={queryClient}>
<Provider store={store}>

View File

@@ -4,7 +4,7 @@ import { Provider } from 'react-redux';
import { fireEvent, render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import * as metricsService from 'api/generated/services/metrics';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import { MetrictypesTypeDTO } from 'api/generated/services/sigNoz.schemas';
import * as appContextHooks from 'providers/App/App';
import store from 'store';
@@ -89,13 +89,10 @@ describe('QueryBuilder', () => {
timeAggregationOption: TimeAggregationOptions.AVG,
spaceAggregationLabels: [],
spaceAggregationOption: SpaceAggregationOptions.AVG_BY,
filters: {
items: [],
op: 'and',
},
filterExpression: '',
},
dispatchMetricInspectionOptions: jest.fn(),
metricType: MetricType.SUM,
metricType: MetrictypesTypeDTO.sum,
inspectionStep: InspectionStep.TIME_AGGREGATION,
inspectMetricsTimeSeries: [],
currentQuery: {
@@ -103,6 +100,7 @@ describe('QueryBuilder', () => {
items: [],
op: 'and',
},
filterExpression: '',
} as any,
setCurrentQuery: jest.fn(),
};

View File

@@ -1,7 +1,7 @@
import { render, screen } from '@testing-library/react';
import { InspectMetricsSeries } from 'api/metricsExplorer/getInspectMetricsDetails';
import TableView from '../TableView';
import { InspectMetricsSeries } from '../types';
import {
InspectionStep,
SpaceAggregationOptions,
@@ -19,12 +19,6 @@ describe('TableView', () => {
{ timestamp: 1234567891000, value: '20' },
],
labels: { label1: 'value1' },
labelsArray: [
{
label: 'label1',
value: 'value1',
},
],
},
{
strokeColor: '#fff',
@@ -34,12 +28,6 @@ describe('TableView', () => {
{ timestamp: 1234567891000, value: '40' },
],
labels: { label2: 'value2' },
labelsArray: [
{
label: 'label2',
value: 'value2',
},
],
},
];
@@ -53,10 +41,7 @@ describe('TableView', () => {
timeAggregationOption: TimeAggregationOptions.MAX,
spaceAggregationOption: SpaceAggregationOptions.MAX_BY,
spaceAggregationLabels: ['host_name'],
filters: {
items: [],
op: 'AND',
},
filterExpression: '',
},
isInspectMetricsRefetching: false,
};

View File

@@ -1,6 +1,6 @@
import { ForwardRefExoticComponent, RefAttributes } from 'react';
import { Color } from '@signozhq/design-tokens';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import { MetrictypesTypeDTO } from 'api/generated/services/sigNoz.schemas';
import {
BarChart,
BarChart2,
@@ -18,25 +18,25 @@ import {
export const INSPECT_FEATURE_FLAG_KEY = 'metrics-explorer-inspect-feature-flag';
export const METRIC_TYPE_TO_COLOR_MAP: Record<MetricType, string> = {
[MetricType.GAUGE]: Color.BG_SAKURA_500,
[MetricType.HISTOGRAM]: Color.BG_SIENNA_500,
[MetricType.SUM]: Color.BG_ROBIN_500,
[MetricType.SUMMARY]: Color.BG_FOREST_500,
[MetricType.EXPONENTIAL_HISTOGRAM]: Color.BG_AQUA_500,
export const METRIC_TYPE_TO_COLOR_MAP: Record<MetrictypesTypeDTO, string> = {
[MetrictypesTypeDTO.gauge]: Color.BG_SAKURA_500,
[MetrictypesTypeDTO.histogram]: Color.BG_SIENNA_500,
[MetrictypesTypeDTO.sum]: Color.BG_ROBIN_500,
[MetrictypesTypeDTO.summary]: Color.BG_FOREST_500,
[MetrictypesTypeDTO.exponentialhistogram]: Color.BG_AQUA_500,
};
export const METRIC_TYPE_TO_ICON_MAP: Record<
MetricType,
MetrictypesTypeDTO,
ForwardRefExoticComponent<
Omit<LucideProps, 'ref'> & RefAttributes<SVGSVGElement>
>
> = {
[MetricType.GAUGE]: Gauge,
[MetricType.HISTOGRAM]: BarChart2,
[MetricType.SUM]: Diff,
[MetricType.SUMMARY]: BarChartHorizontal,
[MetricType.EXPONENTIAL_HISTOGRAM]: BarChart,
[MetrictypesTypeDTO.gauge]: Gauge,
[MetrictypesTypeDTO.histogram]: BarChart2,
[MetrictypesTypeDTO.sum]: Diff,
[MetrictypesTypeDTO.summary]: BarChartHorizontal,
[MetrictypesTypeDTO.exponentialhistogram]: BarChart,
};
export const TIME_AGGREGATION_OPTIONS: Record<
@@ -77,20 +77,14 @@ export const INITIAL_INSPECT_METRICS_OPTIONS: MetricInspectionState = {
timeAggregationInterval: undefined,
spaceAggregationOption: undefined,
spaceAggregationLabels: [],
filters: {
items: [],
op: 'AND',
},
filterExpression: '',
},
appliedOptions: {
timeAggregationOption: undefined,
timeAggregationInterval: undefined,
spaceAggregationOption: undefined,
spaceAggregationLabels: [],
filters: {
items: [],
op: 'AND',
},
filterExpression: '',
},
};

View File

@@ -1,11 +1,19 @@
import { InspectMetricsSeries } from 'api/metricsExplorer/getInspectMetricsDetails';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import {
IBuilderQuery,
TagFilter,
} from 'types/api/queryBuilder/queryBuilderData';
import { MetrictypesTypeDTO } from 'api/generated/services/sigNoz.schemas';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { AlignedData } from 'uplot';
export interface InspectMetricsTimestampValue {
timestamp: number;
value: string;
}
export interface InspectMetricsSeries {
title?: string;
strokeColor?: string;
labels: Record<string, string>;
values: InspectMetricsTimestampValue[];
}
export type InspectProps = {
metricName: string;
isOpen: boolean;
@@ -14,7 +22,6 @@ export type InspectProps = {
export interface UseInspectMetricsReturnData {
inspectMetricsTimeSeries: InspectMetricsSeries[];
inspectMetricsStatusCode: number;
isInspectMetricsLoading: boolean;
isInspectMetricsError: boolean;
formattedInspectMetricsTimeSeries: AlignedData;
@@ -33,7 +40,7 @@ export interface GraphViewProps {
inspectMetricsTimeSeries: InspectMetricsSeries[];
metricUnit: string | undefined;
metricName: string | null;
metricType?: MetricType | undefined;
metricType?: MetrictypesTypeDTO | undefined;
formattedInspectMetricsTimeSeries: AlignedData;
resetInspection: () => void;
spaceAggregationSeriesMap: Map<string, InspectMetricsSeries[]>;
@@ -106,7 +113,7 @@ export interface MetricInspectionOptions {
timeAggregationInterval: number | undefined;
spaceAggregationOption: SpaceAggregationOptions | undefined;
spaceAggregationLabels: string[];
filters: TagFilter;
filterExpression: string;
}
export interface MetricInspectionState {
@@ -119,7 +126,7 @@ export type MetricInspectionAction =
| { type: 'SET_TIME_AGGREGATION_INTERVAL'; payload: number }
| { type: 'SET_SPACE_AGGREGATION_OPTION'; payload: SpaceAggregationOptions }
| { type: 'SET_SPACE_AGGREGATION_LABELS'; payload: string[] }
| { type: 'SET_FILTERS'; payload: TagFilter }
| { type: 'SET_FILTERS'; payload: string }
| { type: 'RESET_INSPECTION' }
| { type: 'APPLY_METRIC_INSPECTION_OPTIONS' };

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useReducer, useState } from 'react';
import { InspectMetricsSeries } from 'api/metricsExplorer/getInspectMetricsDetails';
import { useQuery } from 'react-query';
import { inspectMetrics } from 'api/generated/services/metrics';
import { themeColors } from 'constants/theme';
import { useGetInspectMetricsDetails } from 'hooks/metricsExplorer/useGetInspectMetricsDetails';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
@@ -9,6 +9,7 @@ import { INITIAL_INSPECT_METRICS_OPTIONS } from './constants';
import {
GraphPopoverData,
InspectionStep,
InspectMetricsSeries,
MetricInspectionAction,
MetricInspectionState,
UseInspectMetricsReturnData,
@@ -61,7 +62,7 @@ const metricInspectionReducer = (
...state,
currentOptions: {
...state.currentOptions,
filters: action.payload,
filterExpression: action.payload,
},
};
case 'APPLY_METRIC_INSPECTION_OPTIONS':
@@ -100,26 +101,58 @@ export function useInspectMetrics(
);
const {
data: inspectMetricsData,
data: inspectMetricsResponse,
isLoading: isInspectMetricsLoading,
isError: isInspectMetricsError,
isRefetching: isInspectMetricsRefetching,
} = useGetInspectMetricsDetails(
{
metricName: metricName ?? '',
} = useQuery({
queryKey: [
'inspectMetrics',
metricName,
start,
end,
filters: metricInspectionOptions.appliedOptions.filters,
},
{
enabled: !!metricName,
keepPreviousData: true,
},
metricInspectionOptions.appliedOptions.filterExpression,
],
queryFn: ({ signal }) =>
inspectMetrics(
{
metricName: metricName ?? '',
start,
end,
filter: metricInspectionOptions.appliedOptions.filterExpression
? { expression: metricInspectionOptions.appliedOptions.filterExpression }
: undefined,
},
signal,
),
enabled: !!metricName,
keepPreviousData: true,
});
const inspectMetricsData = useMemo(
() => ({
series: (inspectMetricsResponse?.data?.series ?? []).map((s) => {
const labels: Record<string, string> = {};
for (const l of s.labels ?? []) {
if (l.key?.name) {
labels[l.key.name] = String(l.value ?? '');
}
}
return {
labels,
values: (s.values ?? []).map((v) => ({
timestamp: v.timestamp ?? 0,
value: String(v.value ?? 0),
})),
};
}) as InspectMetricsSeries[],
}),
[inspectMetricsResponse],
);
const isDarkMode = useIsDarkMode();
const inspectMetricsTimeSeries = useMemo(() => {
const series = inspectMetricsData?.payload?.data?.series ?? [];
const series = inspectMetricsData?.series ?? [];
return series.map((series, index) => {
const title = `TS${index + 1}`;
@@ -136,11 +169,6 @@ export function useInspectMetrics(
});
}, [inspectMetricsData, isDarkMode]);
const inspectMetricsStatusCode = useMemo(
() => inspectMetricsData?.statusCode || 200,
[inspectMetricsData],
);
// Evaluate inspection step
const currentInspectionStep = useMemo(() => {
if (metricInspectionOptions.currentOptions.spaceAggregationOption) {
@@ -231,7 +259,7 @@ export function useInspectMetrics(
const spaceAggregationLabels = useMemo(() => {
const labels = new Set<string>();
inspectMetricsData?.payload?.data.series.forEach((series) => {
inspectMetricsData?.series?.forEach((series) => {
Object.keys(series.labels).forEach((label) => {
labels.add(label);
});
@@ -250,7 +278,6 @@ export function useInspectMetrics(
return {
inspectMetricsTimeSeries,
inspectMetricsStatusCode,
isInspectMetricsLoading,
isInspectMetricsError,
formattedInspectMetricsTimeSeries,

View File

@@ -1,7 +1,7 @@
import { MetrictypesTypeDTO } from 'api/generated/services/sigNoz.schemas';
import { InspectMetricsSeries } from 'api/metricsExplorer/getInspectMetricsDetails';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { InspectMetricsSeries } from './types';
import {
GraphPopoverData,
GraphPopoverOptions,

View File

@@ -5,7 +5,6 @@ import {
MetrictypesTemporalityDTO,
MetrictypesTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { Temporality } from 'api/metricsExplorer/getMetricDetails';
import {
UniversalYAxisUnit,
YAxisUnitSelectorProps,
@@ -180,7 +179,10 @@ describe('Metadata', () => {
const temporalitySelect = screen.getByTestId('temporality-select');
expect(temporalitySelect).toBeInTheDocument();
await userEvent.selectOptions(temporalitySelect, Temporality.CUMULATIVE);
await userEvent.selectOptions(
temporalitySelect,
MetrictypesTemporalityDTO.cumulative,
);
const unitSelect = screen.getByTestId('unit-select');
expect(unitSelect).toBeInTheDocument();

View File

@@ -9,16 +9,17 @@ import {
Popover,
Spin,
} from 'antd';
import {
getListMetricsQueryKey,
useListMetrics,
} from 'api/generated/services/metrics';
import { Filter } from 'api/v5/v5';
import {
convertExpressionToFilters,
convertFiltersToExpression,
} from 'components/QueryBuilderV2/utils';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useGetMetricsListFilterValues } from 'hooks/metricsExplorer/useGetMetricsListFilterValues';
import useDebouncedFn from 'hooks/useDebouncedFunction';
import { Search } from 'lucide-react';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
function MetricNameSearch({
queryFilterExpression,
@@ -44,25 +45,27 @@ function MetricNameSearch({
}, [isPopoverOpen]);
const {
data: metricNameFilterValuesData,
data: metricNameListData,
isLoading: isLoadingMetricNameFilterValues,
isError: isErrorMetricNameFilterValues,
} = useGetMetricsListFilterValues(
} = useListMetrics(
{
searchText: debouncedSearchString,
filterKey: 'metric_name',
filterAttributeKeyDataType: DataTypes.String,
limit: 10,
},
{
enabled: isPopoverOpen,
refetchOnWindowFocus: false,
queryKey: [
REACT_QUERY_KEY.GET_METRICS_LIST_FILTER_VALUES,
'metric_name',
debouncedSearchString,
isPopoverOpen,
],
query: {
enabled: isPopoverOpen,
refetchOnWindowFocus: false,
queryKey: [
...getListMetricsQueryKey({
searchText: debouncedSearchString,
limit: 10,
}),
'search',
isPopoverOpen,
],
},
},
);
@@ -95,8 +98,8 @@ function MetricNameSearch({
);
const metricNameFilterValues = useMemo(
() => metricNameFilterValuesData?.payload?.data?.filterValues || [],
[metricNameFilterValuesData],
() => metricNameListData?.data?.metrics?.map((m) => m.metricName) || [],
[metricNameListData],
);
const handleKeyDown = useCallback(

View File

@@ -2,8 +2,8 @@
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import { fireEvent, render, screen } from '@testing-library/react';
import * as metricsGeneratedAPI from 'api/generated/services/metrics';
import { Filter } from 'api/v5/v5';
import * as useGetMetricsListFilterValues from 'hooks/metricsExplorer/useGetMetricsListFilterValues';
import * as useQueryBuilderOperationsHooks from 'hooks/queryBuilder/useQueryBuilderOperations';
import store from 'store';
import APIError from 'types/api/error';
@@ -53,21 +53,33 @@ describe('MetricsTable', () => {
} as any);
});
jest
.spyOn(useGetMetricsListFilterValues, 'useGetMetricsListFilterValues')
.mockReturnValue({
jest.spyOn(metricsGeneratedAPI, 'useListMetrics').mockReturnValue(({
data: {
data: {
statusCode: 200,
payload: {
status: 'success',
data: {
filterValues: ['metric1', 'metric2'],
metrics: [
{
metricName: 'metric1',
description: '',
type: '',
unit: '',
temporality: '',
isMonotonic: false,
},
},
{
metricName: 'metric2',
description: '',
type: '',
unit: '',
temporality: '',
isMonotonic: false,
},
],
},
isLoading: false,
isError: false,
} as any);
status: 'success',
},
isLoading: false,
isError: false,
} as unknown) as ReturnType<typeof metricsGeneratedAPI.useListMetrics>);
it('renders table with data correctly', () => {
render(

View File

@@ -6,7 +6,6 @@ import {
MetricsexplorertypesTreemapEntryDTO,
MetricsexplorertypesTreemapModeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { MetricsListPayload } from 'api/metricsExplorer/getMetricsList';
import { Filter } from 'api/v5/v5';
import { getUniversalNameFromMetricUnit } from 'components/YAxisUnitSelector/utils';
@@ -76,14 +75,6 @@ export const getMetricsTableColumns = (
},
];
export const getMetricsListQuery = (): MetricsListPayload => ({
filters: {
items: [],
op: 'and',
},
orderBy: { columnName: 'metric_name', order: 'asc' },
});
function ValidateRowValueWrapper({
value,
children,

View File

@@ -1,5 +1,5 @@
import { render, screen } from '@testing-library/react';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import { MetrictypesTypeDTO } from 'api/generated/services/sigNoz.schemas';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { ReduceOperators } from 'types/common/queryBuilder';
@@ -47,7 +47,7 @@ describe('ReduceToFilter', () => {
spaceAggregation: 'sum',
},
],
aggregateAttribute: { key: 'test', type: MetricType.SUM },
aggregateAttribute: { key: 'test', type: MetrictypesTypeDTO.sum },
})}
onChange={mockOnChange}
/>,
@@ -61,7 +61,7 @@ describe('ReduceToFilter', () => {
<ReduceToFilter
query={baseQuery({
reduceTo: ReduceOperators.MAX,
aggregateAttribute: { key: 'test', type: MetricType.GAUGE },
aggregateAttribute: { key: 'test', type: MetrictypesTypeDTO.gauge },
})}
onChange={mockOnChange}
/>,

View File

@@ -1,55 +0,0 @@
import { useMemo } from 'react';
import { useQuery, UseQueryOptions, UseQueryResult } from 'react-query';
import {
getInspectMetricsDetails,
InspectMetricsRequest,
InspectMetricsResponse,
} from 'api/metricsExplorer/getInspectMetricsDetails';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { ErrorResponse, SuccessResponse } from 'types/api';
type UseGetInspectMetricsDetails = (
requestData: InspectMetricsRequest,
options?: UseQueryOptions<
SuccessResponse<InspectMetricsResponse> | ErrorResponse,
Error
>,
headers?: Record<string, string>,
) => UseQueryResult<
SuccessResponse<InspectMetricsResponse> | ErrorResponse,
Error
>;
export const useGetInspectMetricsDetails: UseGetInspectMetricsDetails = (
requestData,
options,
headers,
) => {
const queryKey = useMemo(() => {
if (options?.queryKey && Array.isArray(options.queryKey)) {
return [...options.queryKey];
}
if (options?.queryKey && typeof options.queryKey === 'string') {
return options.queryKey;
}
return [
REACT_QUERY_KEY.GET_INSPECT_METRICS_DETAILS,
requestData.metricName,
requestData.start,
requestData.end,
requestData.filters,
];
}, [options?.queryKey, requestData]);
return useQuery<
SuccessResponse<InspectMetricsResponse> | ErrorResponse,
Error
>({
queryFn: ({ signal }) =>
getInspectMetricsDetails(requestData, signal, headers),
...options,
queryKey,
});
};

View File

@@ -1,46 +0,0 @@
import { useMemo } from 'react';
import { useQuery, UseQueryOptions, UseQueryResult } from 'react-query';
import {
getMetricDetails,
MetricDetailsResponse,
} from 'api/metricsExplorer/getMetricDetails';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { ErrorResponse, SuccessResponse } from 'types/api';
type UseGetMetricDetails = (
metricName: string,
options?: UseQueryOptions<
SuccessResponse<MetricDetailsResponse> | ErrorResponse,
Error
>,
headers?: Record<string, string>,
) => UseQueryResult<
SuccessResponse<MetricDetailsResponse> | ErrorResponse,
Error
>;
export const useGetMetricDetails: UseGetMetricDetails = (
metricName,
options,
headers,
) => {
const queryKey = useMemo(() => {
if (options?.queryKey && Array.isArray(options.queryKey)) {
return [...options.queryKey];
}
if (options?.queryKey && typeof options.queryKey === 'string') {
return options.queryKey;
}
return [REACT_QUERY_KEY.GET_METRIC_DETAILS, metricName];
}, [options?.queryKey, metricName]);
return useQuery<SuccessResponse<MetricDetailsResponse> | ErrorResponse, Error>(
{
queryFn: ({ signal }) => getMetricDetails(metricName, signal, headers),
...options,
queryKey,
},
);
};

View File

@@ -1,47 +0,0 @@
import { useMemo } from 'react';
import { useQuery, UseQueryOptions, UseQueryResult } from 'react-query';
import {
getMetricsList,
MetricsListPayload,
MetricsListResponse,
} from 'api/metricsExplorer/getMetricsList';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { ErrorResponse, SuccessResponse } from 'types/api';
type UseGetMetricsList = (
requestData: MetricsListPayload,
options?: UseQueryOptions<
SuccessResponse<MetricsListResponse> | ErrorResponse,
Error
>,
headers?: Record<string, string>,
) => UseQueryResult<
SuccessResponse<MetricsListResponse> | ErrorResponse,
Error
>;
export const useGetMetricsList: UseGetMetricsList = (
requestData,
options,
headers,
) => {
const queryKey = useMemo(() => {
if (options?.queryKey && Array.isArray(options.queryKey)) {
return [...options.queryKey];
}
if (options?.queryKey && typeof options.queryKey === 'string') {
return options.queryKey;
}
return [REACT_QUERY_KEY.GET_METRICS_LIST, requestData];
}, [options?.queryKey, requestData]);
return useQuery<SuccessResponse<MetricsListResponse> | ErrorResponse, Error>({
queryFn: ({ signal }) => getMetricsList(requestData, signal, headers),
...options,
queryKey,
});
};

View File

@@ -1,48 +0,0 @@
import { useMemo } from 'react';
import { useQuery, UseQueryOptions, UseQueryResult } from 'react-query';
import {
getMetricsListFilterKeys,
GetMetricsListFilterKeysParams,
MetricsListFilterKeysResponse,
} from 'api/metricsExplorer/getMetricsListFilterKeys';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { ErrorResponse, SuccessResponse } from 'types/api';
type UseGetMetricsListFilterKeys = (
params: GetMetricsListFilterKeysParams,
options?: UseQueryOptions<
SuccessResponse<MetricsListFilterKeysResponse> | ErrorResponse,
Error
>,
headers?: Record<string, string>,
) => UseQueryResult<
SuccessResponse<MetricsListFilterKeysResponse> | ErrorResponse,
Error
>;
export const useGetMetricsListFilterKeys: UseGetMetricsListFilterKeys = (
params,
options,
headers,
) => {
const queryKey = useMemo(() => {
if (options?.queryKey && Array.isArray(options.queryKey)) {
return [...options.queryKey];
}
if (options?.queryKey && typeof options.queryKey === 'string') {
return options.queryKey;
}
return [REACT_QUERY_KEY.GET_METRICS_LIST_FILTER_KEYS];
}, [options?.queryKey]);
return useQuery<
SuccessResponse<MetricsListFilterKeysResponse> | ErrorResponse,
Error
>({
queryFn: ({ signal }) => getMetricsListFilterKeys(params, signal, headers),
...options,
queryKey,
});
};

View File

@@ -1,42 +0,0 @@
import { useMemo } from 'react';
import { useQuery, UseQueryOptions, UseQueryResult } from 'react-query';
import {
getMetricsListFilterValues,
MetricsListFilterValuesPayload,
MetricsListFilterValuesResponse,
} from 'api/metricsExplorer/getMetricsListFilterValues';
import { ErrorResponse, SuccessResponse } from 'types/api';
type UseGetMetricsListFilterValues = (
payload: MetricsListFilterValuesPayload,
options?: UseQueryOptions<
SuccessResponse<MetricsListFilterValuesResponse> | ErrorResponse,
Error
>,
headers?: Record<string, string>,
) => UseQueryResult<
SuccessResponse<MetricsListFilterValuesResponse> | ErrorResponse,
Error
>;
export const useGetMetricsListFilterValues: UseGetMetricsListFilterValues = (
props,
options,
headers,
) => {
const queryKey = useMemo(() => {
if (options?.queryKey && Array.isArray(options.queryKey)) {
return [...options.queryKey];
}
return [props];
}, [options?.queryKey, props]);
return useQuery<
SuccessResponse<MetricsListFilterValuesResponse> | ErrorResponse,
Error
>({
queryFn: ({ signal }) => getMetricsListFilterValues(props, signal, headers),
...options,
queryKey,
});
};

View File

@@ -1,48 +0,0 @@
import { useMemo } from 'react';
import { useQuery, UseQueryOptions, UseQueryResult } from 'react-query';
import {
getRelatedMetrics,
RelatedMetricsPayload,
RelatedMetricsResponse,
} from 'api/metricsExplorer/getRelatedMetrics';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { ErrorResponse, SuccessResponse } from 'types/api';
type UseGetRelatedMetrics = (
requestData: RelatedMetricsPayload,
options?: UseQueryOptions<
SuccessResponse<RelatedMetricsResponse> | ErrorResponse,
Error
>,
headers?: Record<string, string>,
) => UseQueryResult<
SuccessResponse<RelatedMetricsResponse> | ErrorResponse,
Error
>;
export const useGetRelatedMetrics: UseGetRelatedMetrics = (
requestData,
options,
headers,
) => {
const queryKey = useMemo(() => {
if (options?.queryKey && Array.isArray(options.queryKey)) {
return [...options.queryKey];
}
if (options?.queryKey && typeof options.queryKey === 'string') {
return options.queryKey;
}
return [REACT_QUERY_KEY.GET_RELATED_METRICS, requestData];
}, [options?.queryKey, requestData]);
return useQuery<
SuccessResponse<RelatedMetricsResponse> | ErrorResponse,
Error
>({
queryFn: ({ signal }) => getRelatedMetrics(requestData, signal, headers),
...options,
queryKey,
});
};

View File

@@ -1,7 +1,6 @@
/* eslint-disable sonarjs/cognitive-complexity */
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useDebounce } from 'react-use';
import { getMetricsListFilterValues } from 'api/metricsExplorer/getMetricsListFilterValues';
import { getAttributesValues } from 'api/queryBuilder/getAttributesValues';
import { DATA_TYPE_VS_ATTRIBUTE_VALUES_KEY } from 'constants/queryBuilder';
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
@@ -14,7 +13,6 @@ import {
getTagToken,
isInNInOperator,
} from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import { useGetMetricsListFilterKeys } from 'hooks/metricsExplorer/useGetMetricsListFilterKeys';
import useDebounceValue from 'hooks/useDebounce';
import { cloneDeep, isEqual, uniqWith, unset } from 'lodash-es';
import { IAttributeValuesResponse } from 'types/api/queryBuilder/getAttributesValues';
@@ -152,21 +150,8 @@ export const useFetchKeysAndValues = (
},
);
const {
data: metricsListFilterKeysData,
isFetching: isFetchingMetricsListFilterKeys,
status: fetchingMetricsListFilterKeysStatus,
} = useGetMetricsListFilterKeys(
{
searchText: searchKey,
},
{
enabled: isMetricsExplorer && isQueryEnabled && !shouldUseSuggestions,
queryKey: [searchKey],
},
);
function isAttributeValuesResponse(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
payload: any,
): payload is IAttributeValuesResponse {
return (
@@ -180,14 +165,6 @@ export const useFetchKeysAndValues = (
);
}
function isMetricsListFilterValuesData(
payload: any,
): payload is { filterValues: string[] } {
return (
payload && 'filterValues' in payload && Array.isArray(payload.filterValues)
);
}
/**
* Fetches the options to be displayed based on the selected value
* @param value - the selected value
@@ -231,15 +208,6 @@ export const useFetchKeysAndValues = (
: tagValue?.toString() ?? '',
});
payload = response.payload;
} else if (isMetricsExplorer) {
const response = await getMetricsListFilterValues({
searchText: searchKey,
filterKey: filterAttributeKey?.key ?? tagKey,
filterAttributeKeyDataType:
filterAttributeKey?.dataType ?? DataTypes.EMPTY,
limit: 10,
});
payload = response.payload?.data;
} else {
const response = await getAttributesValues({
aggregateOperator: query.aggregateOperator || '',
@@ -256,18 +224,11 @@ export const useFetchKeysAndValues = (
payload = response.payload;
}
if (payload) {
if (isAttributeValuesResponse(payload)) {
const dataType = filterAttributeKey?.dataType ?? DataTypes.String;
const key = DATA_TYPE_VS_ATTRIBUTE_VALUES_KEY[dataType];
setResults(key ? payload[key] || [] : []);
return;
}
if (isMetricsExplorer && isMetricsListFilterValuesData(payload)) {
setResults(payload.filterValues || []);
return;
}
if (payload && isAttributeValuesResponse(payload)) {
const dataType = filterAttributeKey?.dataType ?? DataTypes.String;
const key = DATA_TYPE_VS_ATTRIBUTE_VALUES_KEY[dataType];
setResults(key ? payload[key] || [] : []);
return;
}
} catch (e) {
console.error(e);
@@ -305,32 +266,6 @@ export const useFetchKeysAndValues = (
}
}, [data?.payload?.attributeKeys, status]);
useEffect(() => {
if (
isMetricsExplorer &&
fetchingMetricsListFilterKeysStatus === 'success' &&
!isFetchingMetricsListFilterKeys &&
metricsListFilterKeysData?.payload?.data?.attributeKeys
) {
setKeys(metricsListFilterKeysData.payload.data.attributeKeys);
setSourceKeys((prevState) =>
uniqWith(
[
...(metricsListFilterKeysData.payload.data.attributeKeys ?? []),
...prevState,
],
isEqual,
),
);
}
}, [
metricsListFilterKeysData?.payload?.data?.attributeKeys,
fetchingMetricsListFilterKeysStatus,
isMetricsExplorer,
metricsListFilterKeysData,
isFetchingMetricsListFilterKeys,
]);
useEffect(() => {
if (
fetchingSuggestionsStatus === 'success' &&

View File

@@ -1,15 +0,0 @@
import { Temporality } from 'api/metricsExplorer/getMetricDetails';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
export interface MetricMetadata {
description: string;
type: MetricType;
unit: string;
temporality: Temporality;
isMonotonic: boolean;
}
export interface MetricMetadataResponse {
status: string;
data: MetricMetadata;
}

4
go.mod
View File

@@ -372,7 +372,7 @@ require (
go.opentelemetry.io/otel/log v0.15.0 // indirect
go.opentelemetry.io/otel/sdk/log v0.14.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.40.0
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
go.opentelemetry.io/proto/otlp v1.9.0
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/mock v0.6.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
@@ -381,7 +381,7 @@ require (
golang.org/x/time v0.14.0 // indirect
golang.org/x/tools v0.41.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260203192932-546029d2fa20 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409
google.golang.org/grpc v1.78.0 // indirect
gopkg.in/telebot.v3 v3.3.8 // indirect
k8s.io/client-go v0.35.0 // indirect

View File

@@ -47,7 +47,7 @@ type Dispatcher struct {
receiverRoutes map[string]*dispatch.Route
}
// We use the upstream Limits interface from Prometheus
// We use the upstream Limits interface from Prometheus.
type Limits = dispatch.Limits
// NewDispatcher returns a new Dispatcher.
@@ -273,7 +273,7 @@ type notifyFunc func(context.Context, ...*types.Alert) bool
// processAlert determines in which aggregation group the alert falls
// and inserts it.
// no data alert will only have ruleId and no data label
// no data alert will only have ruleId and no data label.
func (d *Dispatcher) processAlert(alert *types.Alert, route *dispatch.Route) {
ruleId := getRuleIDFromAlert(alert)
config, err := d.notificationManager.GetNotificationConfig(d.orgID, ruleId)
@@ -510,7 +510,7 @@ func (ag *aggrGroup) flush(notify func(...*types.Alert) bool) {
}
}
// unlimitedLimits provides unlimited aggregation groups for SigNoz
// unlimitedLimits provides unlimited aggregation groups for SigNoz.
type unlimitedLimits struct{}
func (u *unlimitedLimits) MaxNumberOfAggregationGroups() int { return 0 }

View File

@@ -169,19 +169,20 @@ func TestEndToEndAlertManagerFlow(t *testing.T) {
require.Len(t, alerts, 3, "Expected 3 active alerts")
for _, alert := range alerts {
require.Equal(t, "high-cpu-usage", alert.Alert.Labels["ruleId"])
require.NotEmpty(t, alert.Alert.Labels["severity"])
require.Contains(t, []string{"critical", "warning"}, alert.Alert.Labels["severity"])
require.Equal(t, "prod-cluster", alert.Alert.Labels["cluster"])
require.NotEmpty(t, alert.Alert.Labels["instance"])
require.Equal(t, "high-cpu-usage", alert.Labels["ruleId"])
require.NotEmpty(t, alert.Labels["severity"])
require.Contains(t, []string{"critical", "warning"}, alert.Labels["severity"])
require.Equal(t, "prod-cluster", alert.Labels["cluster"])
require.NotEmpty(t, alert.Labels["instance"])
}
criticalAlerts := 0
warningAlerts := 0
for _, alert := range alerts {
if alert.Alert.Labels["severity"] == "critical" {
switch alert.Labels["severity"] {
case "critical":
criticalAlerts++
} else if alert.Alert.Labels["severity"] == "warning" {
case "warning":
warningAlerts++
}
}

View File

@@ -124,7 +124,7 @@ func TestServerPutAlerts(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, 1, len(gettableAlerts))
assert.Equal(t, gettableAlerts[0].Alert.Labels["alertname"], "test-alert")
assert.Equal(t, gettableAlerts[0].Labels["alertname"], "test-alert")
assert.NoError(t, server.Stop(context.Background()))
}

View File

@@ -9,7 +9,7 @@ type DispatcherMetrics struct {
}
// NewDispatcherMetrics returns a new registered DispatchMetrics.
// todo(aniketio-ctrl): change prom metrics to otel metrics
// todo(aniketio-ctrl): change prom metrics to otel metrics.
func NewDispatcherMetrics(registerLimitMetrics bool, r prometheus.Registerer) *DispatcherMetrics {
m := DispatcherMetrics{
aggrGroups: prometheus.NewGauge(

View File

@@ -9,7 +9,7 @@ import (
"github.com/prometheus/common/model"
)
// MockNotificationManager is a simple mock implementation of NotificationManager
// MockNotificationManager is a simple mock implementation of NotificationManager.
type MockNotificationManager struct {
configs map[string]*alertmanagertypes.NotificationConfig
routes map[string]*alertmanagertypes.RoutePolicy
@@ -17,7 +17,7 @@ type MockNotificationManager struct {
errors map[string]error
}
// NewMock creates a new mock notification manager
// NewMock creates a new mock notification manager.
func NewMock() *MockNotificationManager {
return &MockNotificationManager{
configs: make(map[string]*alertmanagertypes.NotificationConfig),

View File

@@ -216,7 +216,7 @@ func (r *provider) Match(ctx context.Context, orgID string, ruleID string, set m
// the nested structure takes precedence. That means we will replace an existing leaf at any
// intermediate path with a map so we can materialize the deeper structure.
// TODO(srikanthccv): we need a better solution to handle this, remove the following
// when we update the expr to support dotted keys
// when we update the expr to support dotted keys.
func (r *provider) convertLabelSetToEnv(ctx context.Context, labelSet model.LabelSet) map[string]interface{} {
env := make(map[string]interface{})

View File

@@ -217,7 +217,7 @@ func (provider *provider) GetConfig(ctx context.Context, orgID string) (*alertma
}
func (provider *provider) SetDefaultConfig(ctx context.Context, orgID string) error {
config, err := alertmanagertypes.NewDefaultConfig(provider.config.Signoz.Config.Global, provider.config.Signoz.Config.Route, orgID)
config, err := alertmanagertypes.NewDefaultConfig(provider.config.Signoz.Global, provider.config.Signoz.Route, orgID)
if err != nil {
return err
}

View File

@@ -183,5 +183,43 @@ func (provider *provider) addMetricsExplorerRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/metrics/inspect", handler.New(
provider.authZ.ViewAccess(provider.metricsExplorerHandler.InspectMetrics),
handler.OpenAPIDef{
ID: "InspectMetrics",
Tags: []string{"metrics"},
Summary: "Inspect raw metric data points",
Description: "Returns raw time series data points for a metric within a time range (max 30 minutes). Each series includes labels and timestamp/value pairs.",
Request: new(metricsexplorertypes.InspectMetricsRequest),
RequestContentType: "application/json",
Response: new(metricsexplorertypes.InspectMetricsResponse),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusInternalServerError},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/metrics/onboarding", handler.New(
provider.authZ.ViewAccess(provider.metricsExplorerHandler.GetOnboardingStatus),
handler.OpenAPIDef{
ID: "GetMetricsOnboardingStatus",
Tags: []string{"metrics"},
Summary: "Check if non-SigNoz metrics have been received",
Description: "Lightweight endpoint that checks if any non-SigNoz metrics have been ingested, used for onboarding status detection",
Request: nil,
RequestContentType: "",
Response: new(metricsexplorertypes.MetricsOnboardingResponse),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusUnauthorized, http.StatusInternalServerError},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
return nil
}

View File

@@ -50,6 +50,11 @@ func (handler *healthOpenAPIHandler) ServeOpenAPI(opCtx openapi.OperationContext
)
}
func (handler *healthOpenAPIHandler) AuditDef() *pkghandler.AuditDef {
// Health endpoints are not audited since they don't represent user actions and are called frequently by monitoring systems, which would create noise in the audit logs.
return nil
}
func (provider *provider) addRegistryRoutes(router *mux.Router) error {
if err := router.Handle("/api/v2/healthz", newHealthOpenAPIHandler(
provider.authZ.OpenAccess(provider.factoryHandler.Healthz),

View File

@@ -3,10 +3,18 @@ package auditor
import (
"context"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/types/audittypes"
)
var (
ErrCodeAuditExportFailed = errors.MustNewCode("audit_export_failed")
)
type Auditor interface {
factory.ServiceWithHealthy
// Audit emits an audit event. It is fire-and-forget: callers never block on audit outcomes.
Audit(ctx context.Context, event audittypes.AuditEvent)
}

View File

@@ -21,11 +21,15 @@ func newTestSettings() factory.ScopedProviderSettings {
func newTestEvent(resource string, action audittypes.Action) audittypes.AuditEvent {
return audittypes.AuditEvent{
Timestamp: time.Now(),
EventName: audittypes.NewEventName(resource, action),
ResourceName: resource,
Action: action,
Outcome: audittypes.OutcomeSuccess,
Timestamp: time.Now(),
EventName: audittypes.NewEventName(resource, action),
AuditAttributes: audittypes.AuditAttributes{
Action: action,
Outcome: audittypes.OutcomeSuccess,
},
ResourceAttributes: audittypes.ResourceAttributes{
ResourceName: resource,
},
}
}

View File

@@ -1,6 +1,7 @@
package auditor
import (
"net/url"
"time"
"github.com/SigNoz/signoz/pkg/errors"
@@ -10,6 +11,7 @@ import (
var _ factory.Config = (*Config)(nil)
type Config struct {
// Provider specifies the audit export implementation to use.
Provider string `mapstructure:"provider"`
// BufferSize is the async channel capacity for audit events.
@@ -28,18 +30,12 @@ type Config struct {
// OTLPHTTPConfig holds configuration for the OTLP HTTP exporter provider.
// Fields map to go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp options.
type OTLPHTTPConfig struct {
// Endpoint is the target host:port (without scheme or path).
Endpoint string `mapstructure:"endpoint"`
// URLPath overrides the default URL path (/v1/logs).
URLPath string `mapstructure:"url_path"`
// Endpoint is the target scheme://host:port of the OTLP HTTP endpoint.
Endpoint *url.URL `mapstructure:"endpoint"`
// Insecure disables TLS, using HTTP instead of HTTPS.
Insecure bool `mapstructure:"insecure"`
// Compression sets the compression strategy. Supported: "none", "gzip".
Compression string `mapstructure:"compression"`
// Timeout is the maximum duration for an export attempt.
Timeout time.Duration `mapstructure:"timeout"`
@@ -71,10 +67,12 @@ func newConfig() factory.Config {
BatchSize: 100,
FlushInterval: time.Second,
OTLPHTTP: OTLPHTTPConfig{
Endpoint: "localhost:4318",
URLPath: "/v1/logs",
Compression: "none",
Timeout: 10 * time.Second,
Endpoint: &url.URL{
Scheme: "http",
Host: "localhost:4318",
Path: "/v1/logs",
},
Timeout: 10 * time.Second,
Retry: RetryConfig{
Enabled: true,
InitialInterval: 5 * time.Second,
@@ -93,14 +91,24 @@ func (c Config) Validate() error {
if c.BufferSize <= 0 {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "auditor::buffer_size must be greater than 0")
}
if c.BatchSize <= 0 {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "auditor::batch_size must be greater than 0")
}
if c.FlushInterval <= 0 {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "auditor::flush_interval must be greater than 0")
}
if c.BatchSize > c.BufferSize {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "auditor::batch_size must not exceed auditor::buffer_size")
}
if c.Provider == "otlphttp" {
if c.OTLPHTTP.Endpoint == nil {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "auditor::otlphttp::endpoint must be set when provider is otlphttp")
}
}
return nil
}

View File

@@ -0,0 +1,42 @@
package noopauditor
import (
"context"
"github.com/SigNoz/signoz/pkg/auditor"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/types/audittypes"
)
type provider struct {
healthyC chan struct{}
stopC chan struct{}
}
func NewFactory() factory.ProviderFactory[auditor.Auditor, auditor.Config] {
return factory.NewProviderFactory(factory.MustNewName("noop"), New)
}
func New(ctx context.Context, providerSettings factory.ProviderSettings, config auditor.Config) (auditor.Auditor, error) {
return &provider{
healthyC: make(chan struct{}),
stopC: make(chan struct{}),
}, nil
}
func (p *provider) Start(_ context.Context) error {
close(p.healthyC)
<-p.stopC
return nil
}
func (p *provider) Stop(_ context.Context) error {
close(p.stopC)
return nil
}
func (p *provider) Healthy() <-chan struct{} {
return p.healthyC
}
func (p *provider) Audit(_ context.Context, _ audittypes.AuditEvent) {}

View File

@@ -210,7 +210,7 @@ func (a *AuthN) fetchGoogleWorkspaceGroups(ctx context.Context, userEmail string
return a.getGroups(ctx, adminService, userEmail, config.FetchTransitiveGroupMembership, checkedGroups)
}
// Recursive method
// Recursive method.
func (a *AuthN) getGroups(ctx context.Context, adminService *admin.Service, userEmail string, fetchTransitive bool, checkedGroups map[string]struct{}) ([]string, error) {
var userGroups []string
var pageToken string

View File

@@ -27,7 +27,7 @@ func NewConf() *Conf {
// NewConfFromMap creates a new Conf instance from a map.
func NewConfFromMap(m map[string]any) (*Conf, error) {
conf := NewConf()
if err := conf.Koanf.Load(confmap.Provider(m, KoanfDelimiter), nil); err != nil {
if err := conf.Load(confmap.Provider(m, KoanfDelimiter), nil); err != nil {
return nil, err
}
@@ -74,7 +74,7 @@ func (conf *Conf) Unmarshal(path string, input any, tags ...string) error {
Result: input,
}
err := conf.Koanf.UnmarshalWithConf(path, input, koanf.UnmarshalConf{Tag: tag, DecoderConfig: dc})
err := conf.UnmarshalWithConf(path, input, koanf.UnmarshalConf{Tag: tag, DecoderConfig: dc})
if err != nil {
return err
}
@@ -93,7 +93,7 @@ func (conf *Conf) Set(key string, input any) error {
}
newConf := NewConf()
if err := newConf.Koanf.Load(confmap.Provider(m, KoanfDelimiter), nil); err != nil {
if err := newConf.Load(confmap.Provider(m, KoanfDelimiter), nil); err != nil {
return err
}

View File

@@ -40,7 +40,7 @@ type WhereClauseRewriter struct {
// In this case, the Severity text will appear in the `labels` if it were part of the group
// by clause, in which case we replace it with the actual value for the notification
// i.e Severity text = WARN
// If the Severity text is not part of the group by clause, then we add it as it is
// If the Severity text is not part of the group by clause, then we add it as it is.
func PrepareFilterExpression(labels map[string]string, whereClause string, groupByItems []qbtypes.GroupByKey) string {
if whereClause == "" && len(labels) == 0 {
return ""
@@ -100,12 +100,12 @@ func PrepareFilterExpression(labels map[string]string, whereClause string, group
return rewrittenClause
}
// Visit implements the visitor for the query rule
// Visit implements the visitor for the query rule.
func (r *WhereClauseRewriter) Visit(tree antlr.ParseTree) interface{} {
return tree.Accept(r)
}
// VisitQuery visits the query node
// VisitQuery visits the query node.
func (r *WhereClauseRewriter) VisitQuery(ctx *parser.QueryContext) interface{} {
if ctx.Expression() != nil {
ctx.Expression().Accept(r)
@@ -113,7 +113,7 @@ func (r *WhereClauseRewriter) VisitQuery(ctx *parser.QueryContext) interface{} {
return nil
}
// VisitExpression visits the expression node
// VisitExpression visits the expression node.
func (r *WhereClauseRewriter) VisitExpression(ctx *parser.ExpressionContext) interface{} {
if ctx.OrExpression() != nil {
ctx.OrExpression().Accept(r)
@@ -121,7 +121,7 @@ func (r *WhereClauseRewriter) VisitExpression(ctx *parser.ExpressionContext) int
return nil
}
// VisitOrExpression visits OR expressions
// VisitOrExpression visits OR expressions.
func (r *WhereClauseRewriter) VisitOrExpression(ctx *parser.OrExpressionContext) interface{} {
for i, andExpr := range ctx.AllAndExpression() {
if i > 0 {
@@ -132,7 +132,7 @@ func (r *WhereClauseRewriter) VisitOrExpression(ctx *parser.OrExpressionContext)
return nil
}
// VisitAndExpression visits AND expressions
// VisitAndExpression visits AND expressions.
func (r *WhereClauseRewriter) VisitAndExpression(ctx *parser.AndExpressionContext) interface{} {
unaryExprs := ctx.AllUnaryExpression()
for i, unaryExpr := range unaryExprs {
@@ -150,7 +150,7 @@ func (r *WhereClauseRewriter) VisitAndExpression(ctx *parser.AndExpressionContex
return nil
}
// VisitUnaryExpression visits unary expressions (with optional NOT)
// VisitUnaryExpression visits unary expressions (with optional NOT).
func (r *WhereClauseRewriter) VisitUnaryExpression(ctx *parser.UnaryExpressionContext) interface{} {
if ctx.NOT() != nil {
r.rewritten.WriteString("NOT ")
@@ -161,7 +161,7 @@ func (r *WhereClauseRewriter) VisitUnaryExpression(ctx *parser.UnaryExpressionCo
return nil
}
// VisitPrimary visits primary expressions
// VisitPrimary visits primary expressions.
func (r *WhereClauseRewriter) VisitPrimary(ctx *parser.PrimaryContext) interface{} {
if ctx.LPAREN() != nil && ctx.RPAREN() != nil {
r.rewritten.WriteString("(")
@@ -183,7 +183,7 @@ func (r *WhereClauseRewriter) VisitPrimary(ctx *parser.PrimaryContext) interface
return nil
}
// VisitComparison visits comparison expressions
// VisitComparison visits comparison expressions.
func (r *WhereClauseRewriter) VisitComparison(ctx *parser.ComparisonContext) interface{} {
if ctx.Key() == nil {
return nil
@@ -304,7 +304,7 @@ func (r *WhereClauseRewriter) VisitComparison(ctx *parser.ComparisonContext) int
return nil
}
// VisitInClause visits IN clauses
// VisitInClause visits IN clauses.
func (r *WhereClauseRewriter) VisitInClause(ctx *parser.InClauseContext) interface{} {
r.rewritten.WriteString("IN ")
if ctx.LPAREN() != nil {
@@ -325,7 +325,7 @@ func (r *WhereClauseRewriter) VisitInClause(ctx *parser.InClauseContext) interfa
return nil
}
// VisitNotInClause visits NOT IN clauses
// VisitNotInClause visits NOT IN clauses.
func (r *WhereClauseRewriter) VisitNotInClause(ctx *parser.NotInClauseContext) interface{} {
r.rewritten.WriteString("NOT IN ")
if ctx.LPAREN() != nil {
@@ -346,7 +346,7 @@ func (r *WhereClauseRewriter) VisitNotInClause(ctx *parser.NotInClauseContext) i
return nil
}
// VisitValueList visits value lists
// VisitValueList visits value lists.
func (r *WhereClauseRewriter) VisitValueList(ctx *parser.ValueListContext) interface{} {
values := ctx.AllValue()
for i, val := range values {
@@ -358,13 +358,13 @@ func (r *WhereClauseRewriter) VisitValueList(ctx *parser.ValueListContext) inter
return nil
}
// VisitFullText visits full text expressions
// VisitFullText visits full text expressions.
func (r *WhereClauseRewriter) VisitFullText(ctx *parser.FullTextContext) interface{} {
r.rewritten.WriteString(ctx.GetText())
return nil
}
// VisitFunctionCall visits function calls
// VisitFunctionCall visits function calls.
func (r *WhereClauseRewriter) VisitFunctionCall(ctx *parser.FunctionCallContext) interface{} {
// Write function name
if ctx.HAS() != nil {
@@ -385,7 +385,7 @@ func (r *WhereClauseRewriter) VisitFunctionCall(ctx *parser.FunctionCallContext)
return nil
}
// VisitFunctionParamList visits function parameter lists
// VisitFunctionParamList visits function parameter lists.
func (r *WhereClauseRewriter) VisitFunctionParamList(ctx *parser.FunctionParamListContext) interface{} {
params := ctx.AllFunctionParam()
for i, param := range params {
@@ -397,7 +397,7 @@ func (r *WhereClauseRewriter) VisitFunctionParamList(ctx *parser.FunctionParamLi
return nil
}
// VisitFunctionParam visits function parameters
// VisitFunctionParam visits function parameters.
func (r *WhereClauseRewriter) VisitFunctionParam(ctx *parser.FunctionParamContext) interface{} {
if ctx.Key() != nil {
ctx.Key().Accept(r)
@@ -409,7 +409,7 @@ func (r *WhereClauseRewriter) VisitFunctionParam(ctx *parser.FunctionParamContex
return nil
}
// VisitArray visits array expressions
// VisitArray visits array expressions.
func (r *WhereClauseRewriter) VisitArray(ctx *parser.ArrayContext) interface{} {
r.rewritten.WriteString("[")
if ctx.ValueList() != nil {
@@ -419,13 +419,13 @@ func (r *WhereClauseRewriter) VisitArray(ctx *parser.ArrayContext) interface{} {
return nil
}
// VisitValue visits value expressions
// VisitValue visits value expressions.
func (r *WhereClauseRewriter) VisitValue(ctx *parser.ValueContext) interface{} {
r.rewritten.WriteString(ctx.GetText())
return nil
}
// VisitKey visits key expressions
// VisitKey visits key expressions.
func (r *WhereClauseRewriter) VisitKey(ctx *parser.KeyContext) interface{} {
r.keysSeen[ctx.GetText()] = struct{}{}
r.rewritten.WriteString(ctx.GetText())
@@ -438,7 +438,7 @@ func (r *WhereClauseRewriter) isKeyInWhereClause(key string) bool {
}
// escapeValueIfNeeded adds single quotes to string values and escapes single quotes within them
// Numeric and boolean values are returned as-is
// Numeric and boolean values are returned as-is.
func escapeValueIfNeeded(value string) string {
// Check if it's a number
if _, err := fmt.Sscanf(value, "%f", new(float64)); err == nil {

View File

@@ -146,7 +146,7 @@ func PrepareLinksToLogs(start, end time.Time, filterItems []v3.FilterItem) strin
// In this case, the Severity text will appear in the `lbls` if it were part of the group
// by clause, in which case we replace it with the actual value for the notification
// i.e Severity text = WARN
// If the Severity text is not part of the group by clause, then we add it as it is
// If the Severity text is not part of the group by clause, then we add it as it is.
func PrepareFilters(labels map[string]string, whereClauseItems []v3.FilterItem, groupByItems []v3.AttributeKey, keys map[string]v3.AttributeKey) []v3.FilterItem {
filterItems := make([]v3.FilterItem, 0)

View File

@@ -5,7 +5,7 @@ import (
"github.com/SigNoz/signoz/pkg/types/ruletypes"
)
// TODO(srikanthccv): Fix the URL management
// TODO(srikanthccv): Fix the URL management.
type URLShareableTimeRange struct {
Start int64 `json:"start"`
End int64 `json:"end"`

View File

@@ -20,6 +20,12 @@ var (
CodeLicenseUnavailable = Code{"license_unavailable"}
)
var (
// Used when reverse engineering an error from a response that doesn't have a code.
// This should never be used in the codebase, and if it is, it's a bug that should be fixed by using proper error handling and including error codes in responses.
CodeUnset = Code{"unset"}
)
var (
codeRegex = regexp.MustCompile(`^[a-z_]+$`)
)

View File

@@ -155,7 +155,7 @@ func (b *base) WithAdditional(a ...string) *base {
// Otherwise, it returns TypeInternal, the original error string
// and the error itself.
//
//lint:ignore ST1008 we want to return arguments in the 'TCMEUA' order of the struct
//nolint:staticcheck // ST1008: intentional return order matching struct field order (TCMEUA)
func Unwrapb(cause error) (typ, Code, string, error, string, []string) {
base, ok := cause.(*base)
if ok {

View File

@@ -16,7 +16,7 @@ var (
TypeLicenseUnavailable = typ{"license-unavailable"}
)
// Defines custom error types
// Defines custom error types.
type typ struct{ s string }
func (t typ) String() string {

View File

@@ -115,7 +115,7 @@ func (f *flagger) Boolean(ctx context.Context, flag featuretypes.Name, evalCtx f
for _, client := range f.clients {
value, err := client.BooleanValue(ctx, flag.String(), defaultValue, evalCtx.Ctx())
if err != nil {
f.settings.Logger().ErrorContext(ctx, "failed to get value from client", errors.Attr(err), slog.Any("flag", flag), slog.String("client", client.Metadata().Name()))
f.settings.Logger().ErrorContext(ctx, "failed to get value from client", errors.Attr(err), slog.Any("flag", flag), slog.String("client", client.Metadata().Domain()))
continue
}
@@ -148,7 +148,7 @@ func (f *flagger) String(ctx context.Context, flag featuretypes.Name, evalCtx fe
for _, client := range f.clients {
value, err := client.StringValue(ctx, flag.String(), defaultValue, evalCtx.Ctx())
if err != nil {
f.settings.Logger().WarnContext(ctx, "failed to get value from client", errors.Attr(err), slog.Any("flag", flag), slog.String("client", client.Metadata().Name()))
f.settings.Logger().WarnContext(ctx, "failed to get value from client", errors.Attr(err), slog.Any("flag", flag), slog.String("client", client.Metadata().Domain()))
continue
}
@@ -181,7 +181,7 @@ func (f *flagger) Float(ctx context.Context, flag featuretypes.Name, evalCtx fea
for _, client := range f.clients {
value, err := client.FloatValue(ctx, flag.String(), defaultValue, evalCtx.Ctx())
if err != nil {
f.settings.Logger().WarnContext(ctx, "failed to get value from client", errors.Attr(err), slog.Any("flag", flag), slog.String("client", client.Metadata().Name()))
f.settings.Logger().WarnContext(ctx, "failed to get value from client", errors.Attr(err), slog.Any("flag", flag), slog.String("client", client.Metadata().Domain()))
continue
}
@@ -214,7 +214,7 @@ func (f *flagger) Int(ctx context.Context, flag featuretypes.Name, evalCtx featu
for _, client := range f.clients {
value, err := client.IntValue(ctx, flag.String(), defaultValue, evalCtx.Ctx())
if err != nil {
f.settings.Logger().WarnContext(ctx, "failed to get value from client", errors.Attr(err), slog.Any("flag", flag), slog.String("client", client.Metadata().Name()))
f.settings.Logger().WarnContext(ctx, "failed to get value from client", errors.Attr(err), slog.Any("flag", flag), slog.String("client", client.Metadata().Domain()))
continue
}
@@ -247,7 +247,7 @@ func (f *flagger) Object(ctx context.Context, flag featuretypes.Name, evalCtx fe
for _, client := range f.clients {
value, err := client.ObjectValue(ctx, flag.String(), defaultValue, evalCtx.Ctx())
if err != nil {
f.settings.Logger().WarnContext(ctx, "failed to get value from client", errors.Attr(err), slog.Any("flag", flag), slog.String("client", client.Metadata().Name()))
f.settings.Logger().WarnContext(ctx, "failed to get value from client", errors.Attr(err), slog.Any("flag", flag), slog.String("client", client.Metadata().Domain()))
continue
}

View File

@@ -15,14 +15,16 @@ type ServeOpenAPIFunc func(openapi.OperationContext)
type Handler interface {
http.Handler
ServeOpenAPI(openapi.OperationContext)
AuditDef() *AuditDef
}
type handler struct {
handlerFunc http.HandlerFunc
openAPIDef OpenAPIDef
auditDef *AuditDef
}
func New(handlerFunc http.HandlerFunc, openAPIDef OpenAPIDef) Handler {
func New(handlerFunc http.HandlerFunc, openAPIDef OpenAPIDef, opts ...Option) Handler {
// Remove duplicate error status codes
openAPIDef.ErrorStatusCodes = slices.DeleteFunc(openAPIDef.ErrorStatusCodes, func(statusCode int) bool {
return statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden || statusCode == http.StatusInternalServerError
@@ -36,10 +38,16 @@ func New(handlerFunc http.HandlerFunc, openAPIDef OpenAPIDef) Handler {
openAPIDef.ErrorStatusCodes = append(openAPIDef.ErrorStatusCodes, http.StatusUnauthorized, http.StatusForbidden)
}
return &handler{
handler := &handler{
handlerFunc: handlerFunc,
openAPIDef: openAPIDef,
}
for _, opt := range opts {
opt(handler)
}
return handler
}
func (handler *handler) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
@@ -120,5 +128,8 @@ func (handler *handler) ServeOpenAPI(opCtx openapi.OperationContext) {
openapi.WithHTTPStatus(statusCode),
)
}
}
func (handler *handler) AuditDef() *AuditDef {
return handler.auditDef
}

View File

@@ -17,7 +17,7 @@ type OpenAPIExample struct {
Value any
}
// Def is the definition of an OpenAPI operation
// Def is the definition of an OpenAPI operation.
type OpenAPIDef struct {
ID string
Tags []string
@@ -40,7 +40,7 @@ type OpenAPISecurityScheme struct {
Scopes []string
}
// Collector is a collector for OpenAPI operations
// OpenAPICollector is a collector for OpenAPI operations.
type OpenAPICollector struct {
collector *openapi.Collector
}

View File

@@ -0,0 +1,24 @@
package handler
import (
"github.com/SigNoz/signoz/pkg/types/audittypes"
)
// Option configures optional behaviour on a handler created by New.
type Option func(*handler)
type AuditDef struct {
ResourceName string // AuthZ Typeable.Name() value, e.g. "dashboard", "user".
Action audittypes.Action // create, update, delete, login, etc.
Category audittypes.ActionCategory // access_control, configuration_change, etc.
ResourceIDParam string // Gorilla mux path param name for the resource ID.
}
// WithAudit attaches an AuditDef to the handler. The actual audit event
// emission is handled by the middleware layer, which reads the AuditDef
// from the matched route's handler.
func WithAuditDef(def AuditDef) Option {
return func(h *handler) {
h.auditDef = &def
}
}

View File

@@ -0,0 +1,169 @@
package middleware
import (
"log/slog"
"net"
"net/http"
"time"
"github.com/gorilla/mux"
semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
"go.opentelemetry.io/otel/trace"
"github.com/SigNoz/signoz/pkg/auditor"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/types/audittypes"
"github.com/SigNoz/signoz/pkg/types/authtypes"
)
const (
logMessage = "::RECEIVED-REQUEST::"
)
type Audit struct {
logger *slog.Logger
excludedRoutes map[string]struct{}
auditor auditor.Auditor
}
func NewAudit(logger *slog.Logger, excludedRoutes []string, auditor auditor.Auditor) *Audit {
excludedRoutesMap := make(map[string]struct{})
for _, route := range excludedRoutes {
excludedRoutesMap[route] = struct{}{}
}
return &Audit{
logger: logger.With(slog.String("pkg", pkgname)),
excludedRoutes: excludedRoutesMap,
auditor: auditor,
}
}
func (middleware *Audit) Wrap(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
start := time.Now()
host, port, _ := net.SplitHostPort(req.Host)
path, err := mux.CurrentRoute(req).GetPathTemplate()
if err != nil {
path = req.URL.Path
}
fields := []any{
string(semconv.ClientAddressKey), req.RemoteAddr,
string(semconv.UserAgentOriginalKey), req.UserAgent(),
string(semconv.ServerAddressKey), host,
string(semconv.ServerPortKey), port,
string(semconv.HTTPRequestSizeKey), req.ContentLength,
string(semconv.HTTPRouteKey), path,
}
responseBuffer := &byteBuffer{}
writer := newResponseCapture(rw, responseBuffer)
next.ServeHTTP(writer, req)
statusCode, writeErr := writer.StatusCode(), writer.WriteError()
// Logging or Audit: skip if the matched route is in the excluded list. This allows us to exclude noisy routes (e.g. health checks) from both logging and audit.
if _, ok := middleware.excludedRoutes[path]; ok {
return
}
middleware.emitAuditEvent(req, writer, path)
fields = append(fields,
string(semconv.HTTPResponseStatusCodeKey), statusCode,
string(semconv.HTTPServerRequestDurationName), time.Since(start),
)
if writeErr != nil {
fields = append(fields, errors.Attr(writeErr))
middleware.logger.ErrorContext(req.Context(), logMessage, fields...)
} else {
if responseBuffer.Len() != 0 {
fields = append(fields, "response.body", responseBuffer.String())
}
middleware.logger.InfoContext(req.Context(), logMessage, fields...)
}
})
}
func (middleware *Audit) emitAuditEvent(req *http.Request, writer responseCapture, routeTemplate string) {
if middleware.auditor == nil {
return
}
def := auditDefFromRequest(req)
if def == nil {
return
}
// extract claims
claims, _ := authtypes.ClaimsFromContext(req.Context())
// extract status code
statusCode := writer.StatusCode()
// extract traces.
span := trace.SpanFromContext(req.Context())
// extract error details.
var errorType, errorCode string
if statusCode >= 400 {
errorType = render.ErrorTypeFromStatusCode(statusCode)
errorCode = render.ErrorCodeFromBody(writer.BodyBytes())
}
event := audittypes.NewAuditEventFromHTTPRequest(
req,
routeTemplate,
statusCode,
span.SpanContext().TraceID(),
span.SpanContext().SpanID(),
def.Action,
def.Category,
claims,
resourceIDFromRequest(req, def.ResourceIDParam),
def.ResourceName,
errorType,
errorCode,
)
middleware.auditor.Audit(req.Context(), event)
}
func auditDefFromRequest(req *http.Request) *handler.AuditDef {
route := mux.CurrentRoute(req)
if route == nil {
return nil
}
actualHandler := route.GetHandler()
if actualHandler == nil {
return nil
}
// The type assertion is necessary because route.GetHandler() returns
// http.Handler, and not every http.Handler on the mux is a handler.Handler
// (e.g. middleware wrappers, raw http.HandlerFunc registrations).
provider, ok := actualHandler.(handler.Handler)
if !ok {
return nil
}
return provider.AuditDef()
}
func resourceIDFromRequest(req *http.Request, param string) string {
if param == "" {
return ""
}
vars := mux.Vars(req)
if vars == nil {
return ""
}
return vars[param]
}

View File

@@ -1,81 +0,0 @@
package middleware
import (
"bytes"
"log/slog"
"net"
"net/http"
"time"
"github.com/gorilla/mux"
semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
"github.com/SigNoz/signoz/pkg/errors"
)
const (
logMessage string = "::RECEIVED-REQUEST::"
)
type Logging struct {
logger *slog.Logger
excludedRoutes map[string]struct{}
}
func NewLogging(logger *slog.Logger, excludedRoutes []string) *Logging {
excludedRoutesMap := make(map[string]struct{})
for _, route := range excludedRoutes {
excludedRoutesMap[route] = struct{}{}
}
return &Logging{
logger: logger.With(slog.String("pkg", pkgname)),
excludedRoutes: excludedRoutesMap,
}
}
func (middleware *Logging) Wrap(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
start := time.Now()
host, port, _ := net.SplitHostPort(req.Host)
path, err := mux.CurrentRoute(req).GetPathTemplate()
if err != nil {
path = req.URL.Path
}
fields := []any{
string(semconv.ClientAddressKey), req.RemoteAddr,
string(semconv.UserAgentOriginalKey), req.UserAgent(),
string(semconv.ServerAddressKey), host,
string(semconv.ServerPortKey), port,
string(semconv.HTTPRequestSizeKey), req.ContentLength,
string(semconv.HTTPRouteKey), path,
}
badResponseBuffer := new(bytes.Buffer)
writer := newBadResponseLoggingWriter(rw, badResponseBuffer)
next.ServeHTTP(writer, req)
// if the path is in the excludedRoutes map, don't log
if _, ok := middleware.excludedRoutes[path]; ok {
return
}
statusCode, err := writer.StatusCode(), writer.WriteError()
fields = append(fields,
string(semconv.HTTPResponseStatusCodeKey), statusCode,
string(semconv.HTTPServerRequestDurationName), time.Since(start),
)
if err != nil {
fields = append(fields, errors.Attr(err))
middleware.logger.ErrorContext(req.Context(), logMessage, fields...)
} else {
// when the status code is 400 or >=500, and the response body is not empty.
if badResponseBuffer.Len() != 0 {
fields = append(fields, "response.body", badResponseBuffer.String())
}
middleware.logger.InfoContext(req.Context(), logMessage, fields...)
}
})
}

View File

@@ -6,15 +6,15 @@ const (
pkgname string = "go.signoz.io/pkg/http/middleware"
)
// Wrapper is an interface implemented by all middlewares
// Wrapper is an interface implemented by all middlewares.
type Wrapper interface {
Wrap(http.Handler) http.Handler
}
// WrapperFunc is to Wrapper as http.HandlerFunc is to http.Handler
// WrapperFunc is to Wrapper as http.HandlerFunc is to http.Handler.
type WrapperFunc func(http.Handler) http.Handler
// WrapperFunc implements Wrapper
// WrapperFunc implements Wrapper.
func (m WrapperFunc) Wrap(next http.Handler) http.Handler {
return m(next)
}

View File

@@ -2,7 +2,6 @@ package middleware
import (
"bufio"
"io"
"net"
"net/http"
@@ -10,118 +9,156 @@ import (
)
const (
maxResponseBodyInLogs = 4096 // At most 4k bytes from response bodies in our logs.
maxResponseBodyCapture int = 4096 // At most 4k bytes from response bodies.
)
type badResponseLoggingWriter interface {
// Wraps an http.ResponseWriter to capture the status code,
// write errors, and (for error responses) a bounded slice of the body.
type responseCapture interface {
http.ResponseWriter
// Get the status code.
// StatusCode returns the HTTP status code written to the response.
StatusCode() int
// Get the error while writing.
// WriteError returns the error (if any) from the downstream Write call.
WriteError() error
// BodyBytes returns the captured response body bytes. Only populated
// for error responses (status >= 400).
BodyBytes() []byte
}
func newBadResponseLoggingWriter(rw http.ResponseWriter, buffer io.Writer) badResponseLoggingWriter {
b := nonFlushingBadResponseLoggingWriter{
func newResponseCapture(rw http.ResponseWriter, buffer *byteBuffer) responseCapture {
b := nonFlushingResponseCapture{
rw: rw,
buffer: buffer,
logBody: false,
bodyBytesLeft: maxResponseBodyInLogs,
captureBody: false,
bodyBytesLeft: maxResponseBodyCapture,
statusCode: http.StatusOK,
}
if f, ok := rw.(http.Flusher); ok {
return &flushingBadResponseLoggingWriter{b, f}
return &flushingResponseCapture{nonFlushingResponseCapture: b, f: f}
}
return &b
}
type nonFlushingBadResponseLoggingWriter struct {
rw http.ResponseWriter
buffer io.Writer
logBody bool
bodyBytesLeft int
statusCode int
writeError error // The error returned when downstream Write() fails.
// byteBuffer is a minimal write-only buffer used to capture response bodies.
type byteBuffer struct {
buf []byte
}
// Extends nonFlushingBadResponseLoggingWriter that implements http.Flusher
type flushingBadResponseLoggingWriter struct {
nonFlushingBadResponseLoggingWriter
func (b *byteBuffer) Write(p []byte) (int, error) {
b.buf = append(b.buf, p...)
return len(p), nil
}
func (b *byteBuffer) WriteString(s string) (int, error) {
b.buf = append(b.buf, s...)
return len(s), nil
}
func (b *byteBuffer) Bytes() []byte {
return b.buf
}
func (b *byteBuffer) Len() int {
return len(b.buf)
}
func (b *byteBuffer) String() string {
return string(b.buf)
}
type nonFlushingResponseCapture struct {
rw http.ResponseWriter
buffer *byteBuffer
captureBody bool
bodyBytesLeft int
statusCode int
writeError error
}
type flushingResponseCapture struct {
nonFlushingResponseCapture
f http.Flusher
}
// Unwrap method is used by http.ResponseController to get access to original http.ResponseWriter.
func (writer *nonFlushingBadResponseLoggingWriter) Unwrap() http.ResponseWriter {
// Unwrap is used by http.ResponseController to get access to original http.ResponseWriter.
func (writer *nonFlushingResponseCapture) Unwrap() http.ResponseWriter {
return writer.rw
}
// Header returns the header map that will be sent by WriteHeader.
// Implements ResponseWriter.
func (writer *nonFlushingBadResponseLoggingWriter) Header() http.Header {
func (writer *nonFlushingResponseCapture) Header() http.Header {
return writer.rw.Header()
}
// WriteHeader writes the HTTP response header.
func (writer *nonFlushingBadResponseLoggingWriter) WriteHeader(statusCode int) {
func (writer *nonFlushingResponseCapture) WriteHeader(statusCode int) {
writer.statusCode = statusCode
if statusCode >= 500 || statusCode == 400 {
writer.logBody = true
if statusCode >= 400 {
writer.captureBody = true
}
writer.rw.WriteHeader(statusCode)
}
// Writes HTTP response data.
func (writer *nonFlushingBadResponseLoggingWriter) Write(data []byte) (int, error) {
// Write writes HTTP response data.
func (writer *nonFlushingResponseCapture) Write(data []byte) (int, error) {
if writer.statusCode == 0 {
// WriteHeader has (probably) not been called, so we need to call it with StatusOK to fulfill the interface contract.
// https://godoc.org/net/http#ResponseWriter
writer.WriteHeader(http.StatusOK)
}
// 204 No Content is a success response that indicates that the request has been successfully processed and that the response body is intentionally empty.
if writer.statusCode == 204 {
return 0, nil
}
n, err := writer.rw.Write(data)
if writer.logBody {
if writer.captureBody {
writer.captureResponseBody(data)
}
if err != nil {
writer.writeError = err
}
return n, err
}
// Hijack hijacks the first response writer that is a Hijacker.
func (writer *nonFlushingBadResponseLoggingWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
func (writer *nonFlushingResponseCapture) Hijack() (net.Conn, *bufio.ReadWriter, error) {
hj, ok := writer.rw.(http.Hijacker)
if ok {
return hj.Hijack()
}
return nil, nil, errors.NewInternalf(errors.CodeInternal, "cannot cast underlying response writer to Hijacker")
}
func (writer *nonFlushingBadResponseLoggingWriter) StatusCode() int {
func (writer *nonFlushingResponseCapture) StatusCode() int {
return writer.statusCode
}
func (writer *nonFlushingBadResponseLoggingWriter) WriteError() error {
func (writer *nonFlushingResponseCapture) WriteError() error {
return writer.writeError
}
func (writer *flushingBadResponseLoggingWriter) Flush() {
func (writer *nonFlushingResponseCapture) BodyBytes() []byte {
return writer.buffer.Bytes()
}
func (writer *flushingResponseCapture) Flush() {
writer.f.Flush()
}
func (writer *nonFlushingBadResponseLoggingWriter) captureResponseBody(data []byte) {
func (writer *nonFlushingResponseCapture) captureResponseBody(data []byte) {
if len(data) > writer.bodyBytesLeft {
_, _ = writer.buffer.Write(data[:writer.bodyBytesLeft])
_, _ = io.WriteString(writer.buffer, "...")
_, _ = writer.buffer.WriteString("...")
writer.bodyBytesLeft = 0
writer.logBody = false
writer.captureBody = false
} else {
_, _ = writer.buffer.Write(data)
writer.bodyBytesLeft -= len(data)

View File

@@ -0,0 +1,88 @@
package middleware
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func TestResponseCapture(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
handler http.HandlerFunc
expectedStatus int
expectedBodyBytes string
expectedClientBody string
}{
{
name: "Success_DoesNotCaptureBody",
handler: func(rw http.ResponseWriter, req *http.Request) {
rw.WriteHeader(http.StatusOK)
_, _ = rw.Write([]byte(`{"status":"success","data":{"id":"123"}}`))
},
expectedStatus: http.StatusOK,
expectedBodyBytes: "",
expectedClientBody: `{"status":"success","data":{"id":"123"}}`,
},
{
name: "Error_CapturesBody",
handler: func(rw http.ResponseWriter, req *http.Request) {
rw.WriteHeader(http.StatusForbidden)
_, _ = rw.Write([]byte(`{"status":"error","error":{"code":"authz_forbidden","message":"forbidden"}}`))
},
expectedStatus: http.StatusForbidden,
expectedBodyBytes: `{"status":"error","error":{"code":"authz_forbidden","message":"forbidden"}}`,
expectedClientBody: `{"status":"error","error":{"code":"authz_forbidden","message":"forbidden"}}`,
},
{
name: "Error_TruncatesAtMaxCapture",
handler: func(rw http.ResponseWriter, req *http.Request) {
rw.WriteHeader(http.StatusInternalServerError)
_, _ = rw.Write([]byte(strings.Repeat("x", maxResponseBodyCapture+100)))
},
expectedStatus: http.StatusInternalServerError,
expectedBodyBytes: strings.Repeat("x", maxResponseBodyCapture) + "...",
expectedClientBody: strings.Repeat("x", maxResponseBodyCapture+100),
},
{
name: "NoContent_SuppressesWrite",
handler: func(rw http.ResponseWriter, req *http.Request) {
rw.WriteHeader(http.StatusNoContent)
_, _ = rw.Write([]byte("should be suppressed"))
},
expectedStatus: http.StatusNoContent,
expectedBodyBytes: "",
expectedClientBody: "",
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
t.Parallel()
var captured responseCapture
server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
buf := &byteBuffer{}
captured = newResponseCapture(rw, buf)
testCase.handler(captured, req)
}))
defer server.Close()
resp, err := http.Get(server.URL)
assert.NoError(t, err)
defer resp.Body.Close()
clientBody, _ := io.ReadAll(resp.Body)
assert.Equal(t, testCase.expectedStatus, captured.StatusCode())
assert.Equal(t, testCase.expectedBodyBytes, string(captured.BodyBytes()))
assert.Equal(t, testCase.expectedClientBody, string(clientBody))
})
}
}

View File

@@ -5,6 +5,7 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
jsoniter "github.com/json-iterator/go"
"github.com/tidwall/gjson"
)
const (
@@ -42,6 +43,43 @@ func Success(rw http.ResponseWriter, httpCode int, data interface{}) {
_, _ = rw.Write(body)
}
func ErrorCodeFromBody(body []byte) string {
code := gjson.GetBytes(body, "error.code").String()
// This should never return empty since we only call this function on responses that were generated by us.
// If it does return empty, the codebase has failed to use render package for error responses somewhere, and we should fix that instead of trying to handle it here.
if code == "" {
return errors.CodeUnset.String()
}
return code
}
func ErrorTypeFromStatusCode(statusCode int) string {
// We are losing the exact type information here, but we can at least capture the error code and message for better observability.
// To get the exact type, we would need some changes in the render package to include the error type in the response, which we can consider in the future if there is a need for it.
switch statusCode {
case http.StatusBadRequest:
return errors.TypeInvalidInput.String()
case http.StatusNotFound:
return errors.TypeNotFound.String()
case http.StatusConflict:
return errors.TypeAlreadyExists.String()
case http.StatusUnauthorized:
return errors.TypeUnauthenticated.String()
case http.StatusNotImplemented:
return errors.TypeUnsupported.String()
case http.StatusForbidden:
return errors.TypeForbidden.String()
case statusClientClosedConnection:
return errors.TypeCanceled.String()
case http.StatusGatewayTimeout:
return errors.TypeTimeout.String()
default:
return errors.TypeInternal.String()
}
}
func Error(rw http.ResponseWriter, cause error) {
// Derive the http code from the error type
t, _, _, _, _, _ := errors.Unwrapb(cause)

View File

@@ -58,6 +58,31 @@ func TestSuccess(t *testing.T) {
assert.Equal(t, expected, actual)
}
func TestErrorCodeFromBody(t *testing.T) {
testCases := []struct {
name string
body []byte
wantCode string
}{
{
name: "ValidErrorResponse",
body: []byte(`{"status":"error","error":{"code":"authz_forbidden","message":"only admins can access this resource"}}`),
wantCode: "authz_forbidden",
},
{
name: "InvalidJSON",
body: []byte(`not json`),
wantCode: "unset",
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
assert.Equal(t, testCase.wantCode, ErrorCodeFromBody(testCase.body))
})
}
}
func TestError(t *testing.T) {
listener, err := net.Listen("tcp", "localhost:0")
require.NoError(t, err)

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