Compare commits

..

1 Commits

Author SHA1 Message Date
srikanthccv
38a1711ee8 test(querier): pin explicit context resolution under ambiguous names
One name can exist in more than one place. `name` is a span column and a
span attribute. `severity_text` is a log column and a log attribute. An
attribute can have two data types. `service.name` is a resource attribute
and a span or log attribute. These tests record what the query builder
does for each shape in a filter, EXISTS, a group by, an order by, an
aggregation argument, and a raw select. A change to name resolution then
fails here first.

- A key with an explicit context reads that context only. A bare key with
  more than one reading returns an ambiguity warning. An `attribute.` key
  returns the warning when the attribute has two data types.
- A bare key that is a column and an attribute reads both in a filter. It
  orders, groups, and counts by the column only. A string operand matches
  a number attribute through a text cast.
- A bare key that is a resource attribute and an attribute reads the
  resource attribute in a filter and in a raw select. The filter returns a
  warning.
- An `attribute.` key in an order by sorts by the attribute on traces and
  by the column on logs.
- A key under the signal's own context that exists only as an attribute
  reads the attribute. On logs it also reads the body JSON path. A
  `scope.` key on logs resolves through metadata only. When metadata does
  not report the key, the query fails with "key not found". This is also
  true for the declared path `scope.name`.

Assisted-by: Claude Fable 5.1
Claude-Session: https://claude.ai/code/session_01RSnZFLSfyi5S4QYQDcxHeW
2026-09-09 14:48:32 +05:30
93 changed files with 3593 additions and 2032 deletions

View File

@@ -1,7 +0,0 @@
{
"$schema": "https://opencode.ai/config.json",
"lsp": true,
"experimental": {
"disable_paste_summary": true
}
}

View File

@@ -7,6 +7,5 @@ Applies to everything in the repo — code, config, workflows.
- **Rationale goes in prose, not source.** Why a version is pinned, why a job exists, how a subsystem fits together — that belongs in the README or the PR.
- **Never remove pre-existing comments** when editing code. The bar above applies to comments you write, not comments already there.
- **Never talk to the reviewer.** No comments about where a change came from, what was changed, or why the change is correct — that belongs in the PR description and is noise the moment it merges.
- **Less is more.** When writing something intended for human consumption, (comment, commit message, reply to prompt) use as few words as possible. Pick every word meticulously to reduce the volume to a strict minimum. Be down to the point. Less is more.
Language rules build on this one: [`go-comments`](go-comments.md), [`py-comments`](py-comments.md).

View File

@@ -1,16 +0,0 @@
---
paths:
- "**/*.go"
---
# Contribution guidelines
- When making Go changes, always ensure they follow the contributing guildelines in [`docs/contributing/go/`](../../docs/contributing/go/).
- Look for existing patterns in the codebase for any change before implementing the changes.
- If any API contract is modified, generate the OpenAPI specs with `make gen-openapi-specs`.
- Always keep the OpenAPI spec generated in a separate commit, so the whole commit can be dropped in case of conflicts during merge. Do not try to resolve conflict in generated files, instead just generate them again.
- Avoid breaking function calls unncessarily into multilines for couple of arguments.
- Try to keep most computational only logic in types package itself related to a domain type, use modules as the orchestraction layer cordinating different layers and all db queries in store layer. Check the serviceaccount modules for inspiration when confused.
- When defining types, keep the structure of file to have any constants and variables first, then exported types and exported methods and then finally the unexported types and methods.
- Never import types or other modules in migration files, duplicate the required type or method to keep migration free from changes.
- Always run the gofmt tool for formating beforing commiting any changes.

View File

@@ -2,10 +2,6 @@
- **Follow the template** (`.github/pull_request_template.md`): fill in its headings (Description / Issues closed by this PR / Screenshots / Additional Information). Don't add sections the template doesn't have.
- **Keep only the headings that apply.** Delete every heading that has nothing under it, along with its `<!--...-->` placeholder comment. The body must never contain an empty heading — if only Description applies, the body has exactly that one heading.
- **Keep the description concise and human-readable.** A few non repetitive bullets saying what changed and why, for a reviewer skimming it — not a wall of text, not a restatement of the diff, not generated boilerplate and not the user agent conversation details.
- **Keep the description concise and human-readable.** A few plain bullets saying what changed and why, for a reviewer skimming it — not a wall of text, not a restatement of the diff, not generated boilerplate.
- **Reference issues with `Closes #issue-number`** under "Issues closed by this PR" so they auto-close on merge. This goes in the PR description only — never in commit messages.
- **Breaking changes can be added in additional information section** if any.
- **AI assistance in commits may optionally be disclosed with an `Assisted-by:` trailer** naming the model (e.g. `Assisted-by: Claude Opus 4.5`) — do NOT use a `Co-authored-by:` trailer for this.
- **Keep the commit body short and human readable** focused on decision made if any. Commit body must not re-iterate the changes done, skip if title is sufficient in conveying the change.
- **Use convensional commit format** for commits and PR title.
- **Do not amend the commits once pushed.** Always create a new commit once changes are pushed to remote.

4
.github/CODEOWNERS vendored
View File

@@ -15,10 +15,6 @@
.github @therealpandey
go.mod @therealpandey
# Security
/SECURITY.md @therealpandey
# Scaffold Owners
/pkg/config/ @therealpandey

1
.gitignore vendored
View File

@@ -232,4 +232,3 @@ pyrightconfig.json
# dev
.dev/
.claude/worktrees/
.claude/settings.local.json

View File

@@ -81,13 +81,10 @@ devenv-clickhouse-clean: ## Clean all ClickHouse data from filesystem
##############################################################
# go commands
##############################################################
SIGNOZ_SQLSTORE_SQLITE_PATH ?= signoz.db
SIGNOZ_APISERVER_ADDRESS ?= 0.0.0.0:8080
.PHONY: go-run-enterprise
go-run-enterprise: ## Runs the enterprise go backend server
@SIGNOZ_INSTRUMENTATION_LOGS_LEVEL=debug \
SIGNOZ_SQLSTORE_SQLITE_PATH=$(SIGNOZ_SQLSTORE_SQLITE_PATH) \
SIGNOZ_SQLSTORE_SQLITE_PATH=signoz.db \
SIGNOZ_WEB_ENABLED=false \
SIGNOZ_TOKENIZER_JWT_SECRET=secret \
SIGNOZ_ALERTMANAGER_PROVIDER=signoz \
@@ -104,7 +101,7 @@ go-test: ## Runs go unit tests
.PHONY: go-run-community
go-run-community: ## Runs the community go backend server
@SIGNOZ_INSTRUMENTATION_LOGS_LEVEL=debug \
SIGNOZ_SQLSTORE_SQLITE_PATH=$(SIGNOZ_SQLSTORE_SQLITE_PATH) \
SIGNOZ_SQLSTORE_SQLITE_PATH=signoz.db \
SIGNOZ_WEB_ENABLED=false \
SIGNOZ_TOKENIZER_JWT_SECRET=secret \
SIGNOZ_ALERTMANAGER_PROVIDER=signoz \
@@ -114,28 +111,6 @@ go-run-community: ## Runs the community go backend server
go run -race \
$(GO_BUILD_CONTEXT_COMMUNITY)/*.go server
.PHONY: go-stop
go-stop: ## Stops the go backend server listening on SIGNOZ_APISERVER_ADDRESS, waiting for it to release every port it holds
@PORT=$(lastword $(subst :, ,$(SIGNOZ_APISERVER_ADDRESS))); \
PIDS=$$(lsof -ti tcp:$$PORT); \
if [ -z "$$PIDS" ]; then \
echo "No signoz server running on port $$PORT."; \
echo "If it's running on a different port, rerun as: make go-stop SIGNOZ_APISERVER_ADDRESS=host:port"; \
exit 0; \
fi; \
kill $$PIDS 2>/dev/null; \
for i in $$(seq 1 10); do \
alive=$$(for p in $$PIDS; do kill -0 $$p 2>/dev/null && echo $$p; done); \
[ -z "$$alive" ] && break; \
sleep 1; \
done; \
alive=$$(for p in $$PIDS; do kill -0 $$p 2>/dev/null && echo $$p; done); \
if [ -n "$$alive" ]; then \
echo "Graceful shutdown did not finish in 10s, sending SIGKILL to $$alive"; \
kill -9 $$alive 2>/dev/null; \
fi; \
echo "Stopped signoz server on port $$PORT (pid $$PIDS)"
.PHONY: go-build-community $(GO_BUILD_ARCHS_COMMUNITY)
go-build-community: ## Builds the go backend server for community
go-build-community: $(GO_BUILD_ARCHS_COMMUNITY)
@@ -266,8 +241,3 @@ semconv-generate: ## Regenerate semantic-convention families for Go and TypeScri
gen-mocks:
@echo ">> Generating mocks"
@mockery --config .mockery.yml
.PHONY: gen-openapi-specs
gen-openapi-specs:
@go run cmd/enterprise/*.go generate openapi
cd frontend && pnpm generate:api && cd -

View File

@@ -1,26 +1,17 @@
# Security Policy
SigNoz is looking forward to working with security researchers across the world to keep SigNoz and our users safe. If you have found an issue in our systems/applications, please report it to us privately.
SigNoz is looking forward to working with security researchers across the world to keep SigNoz and our users safe. If you have found an issue in our systems/applications, please reach out to us.
## Supported Versions
We always recommend using the latest version of SigNoz to ensure you get all security updates.
We always recommend using the latest version of SigNoz to ensure you get all security updates
## Reporting a Vulnerability
If you believe you have found a security vulnerability within SigNoz, please let us know right away. We'll try and fix the problem as soon as possible.
**Do not report vulnerabilities using public GitHub issues, discussions, or pull requests.**
**Do not report vulnerabilities using public GitHub issues**. Instead, email <security@signoz.io> with a detailed account of the issue. Please submit one issue per email, this helps us triage vulnerabilities.
Instead, report it privately through GitHub's private vulnerability reporting:
1. Go to the [**Security** tab](https://github.com/SigNoz/signoz/security) of this repository.
2. Click **Report a vulnerability**, or use [this link](https://github.com/SigNoz/signoz/security/advisories/new).
3. Describe the issue with as much detail as you can — affected version, impact, and steps to reproduce help us triage faster. Please submit one report per vulnerability.
This opens a private advisory visible only to you and the SigNoz maintainers. We'll respond there, keep you updated as we work on a fix, and coordinate disclosure. If the report is valid we'll credit you on the published advisory and request a CVE.
If you're unable to use GitHub's private reporting, you can email <security@signoz.io> instead.
Once we've received your email we'll keep you updated as we fix the vulnerability.
## Thanks

View File

@@ -138,12 +138,6 @@ sqlstore:
##################### APIServer #####################
apiserver:
# The TCP address the API server listens on, in the form "host:port".
address: 0.0.0.0:8080
# Maximum duration for reading an entire request, including the body.
read_timeout: 60s
# Keep at 0; any value cuts off streaming endpoints (livetail, SSE, export_raw_data).
write_timeout: 0
timeout:
# Default request timeout.
default: 60s

View File

@@ -83,13 +83,7 @@ This command:
You should see: `{"status":"ok"}`
3. Stop it when you're done:
```bash
make go-stop
```
> 💡 **Tip**: The API server runs at `http://localhost:8080/` by default. You can configure this using `apiserver.address` configuration option. See
> [running more than one instance](#how-do-i-run-more-than-one-instance) if you need that for agentic testing.
> 💡 **Tip**: The API server runs at `http://localhost:8080/` by default
### 4. Setting up the Frontend
@@ -125,36 +119,6 @@ To verify everything is working correctly:
3. **Check Backend**: `curl http://localhost:8080/api/v1/health` (should return `{"status":"ok"}`)
4. **Check Frontend**: Open `http://localhost:3301` in your browser
## How do I run more than one instance?
Handy when you keep several branches checked out as separate git worktrees. Every port
and path below is read from the environment, so set them on the `make` call:
```bash
SIGNOZ_APISERVER_ADDRESS=0.0.0.0:8081 \
SIGNOZ_SQLSTORE_SQLITE_PATH=/path/to/main/sqlite.db \
SIGNOZ_INSTRUMENTATION_METRICS_READERS_PULL_EXPORTER_PROMETHEUS_PORT=9091 \
make go-run-community
```
| Variable | Default | Why you'd change it |
| --- | --- | --- |
| `SIGNOZ_APISERVER_ADDRESS` | `0.0.0.0:8080` | Address the API server listens on |
| `SIGNOZ_SQLSTORE_SQLITE_PATH` | `signoz.db` in worktree | To reuse same database |
| `SIGNOZ_INSTRUMENTATION_METRICS_READERS_PULL_EXPORTER_PROMETHEUS_PORT` | `9090` | Bound by the Prometheus metrics exporter on startup |
Point the frontend at whichever backend you want, in `frontend/.env`:
```env
VITE_FRONTEND_API_ENDPOINT=http://localhost:8081
```
Stop an instance using the address it was started on:
```bash
make go-stop SIGNOZ_APISERVER_ADDRESS=0.0.0.0:8081
```
## How to send test data?
You can now send telemetry data to your local SigNoz instance:

View File

@@ -9,16 +9,15 @@ change breaks an invariant, flag it and discuss it first.
---
## Why the provider looks like this
## Why a second provider
The removed v1 provider served the promql engine through the remote-read
protobuf adapter. It fetched every raw sample of a query's union window,
serialized all of them, and gave them to the engine. The cost followed the
ingested data, not the question. This is how a dashboard of PromQL panels
could take an instance down. v2 replaced it after a byte-level parity
rollout, and v1 was then deleted.
The v1 provider (`pkg/prometheus/clickhouseprometheus`) serves the promql
engine through the remote-read protobuf adapter. It fetches every raw sample
of a query's union window. It serializes all of them and gives them to the
engine. The cost follows the ingested data, not the question. This is how a
dashboard of PromQL panels can take an instance down.
Each query runs in one of two ways. The classifier decides per query:
In v2, each query runs in one of two ways. The classifier decides per query:
- **Transpiled**: ClickHouse evaluates the query. Only final (or near-final)
per-group grid arrays come back. The statements use the
@@ -31,7 +30,7 @@ Each query runs in one of two ways. The classifier decides per query:
lost user. A construct that cannot reproduce engine semantics exactly falls
back. It does not approximate.** The conformance suite
(`tests/integration/tests/promqlconformance/`) replays Prometheus' own test
corpus against the provider. It is the arbiter. The classification golden
corpus against both providers. It is the arbiter. The classification golden
(`testdata/classification_golden.json`) freezes the route of each corpus
expression. The rest of this document is the PromQL-to-SQL story. That
mapping is where correctness is won or lost.
@@ -264,8 +263,7 @@ per-thread partials scaled memory with the thread count. The slide then
combines each slot's at-most-W bucket partials by direct aggregation
(`arraySum(arraySlice(...))`). Window sums are added the way the engine adds
them. There is no prefix-sum differencing: its large-minus-large
cancellation would drift past the conformance tolerance on counter-sized
values.
cancellation would drift past the shadow tolerance on counter-sized values.
This is correct per slot because the bucket union is the exact window
multiset, and avg/min/max/sum/count are order-insensitive on a multiset
(sum/avg up to summation order; see the float caveat above). A slot with
@@ -335,7 +333,7 @@ can carry them.
## The engine path
Queries that do not transpile run in the stock engine over this package's
`storage.Querier`. Samples are fetched per
`storage.Querier`. This is still not the v1 path. Samples are fetched per
selector with the engine's per-selector hints, not the query-wide union
window. So `foo / foo offset 1d` reads two narrow windows, not the widest
one twice. Instant selectors of subquery-free queries fetch only the last
@@ -369,9 +367,9 @@ same predicates as a shard-local semi-join, not a GLOBAL broadcast of the
matched set. The temporality filter on every samples statement is a
semantic no-op: the matched fingerprints already come from those
temporalities. It engages the leading samples primary-key column.
Delta-temporality series stay invisible to PromQL here, as they were before
v2. To make Delta visible is its own change with its own semantics to
design. A Delta stream fed to `rate()`
Delta-temporality series stay invisible to PromQL here, exactly as in v1.
The rollout gate is parity with v1. To make Delta visible is its own change
with its own semantics to design. A Delta stream fed to `rate()`
as-if-cumulative would be wrong, not just new.
## Observability

View File

@@ -3,29 +3,56 @@ package app
import (
"context"
"fmt"
"net"
"net/http"
"slices"
"go.opentelemetry.io/contrib/instrumentation/github.com/gorilla/mux/otelmux"
"go.opentelemetry.io/otel/propagation"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/gorilla/handlers"
"github.com/rs/cors"
"github.com/soheilhy/cmux"
"github.com/SigNoz/signoz/ee/query-service/app/api"
"github.com/SigNoz/signoz/ee/query-service/usage"
"github.com/SigNoz/signoz/pkg/http/middleware"
"github.com/SigNoz/signoz/pkg/signoz"
"github.com/SigNoz/signoz/pkg/web"
"log/slog"
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
baseapp "github.com/SigNoz/signoz/pkg/query-service/app"
"github.com/SigNoz/signoz/pkg/query-service/app/clickhouseReader"
"github.com/SigNoz/signoz/pkg/query-service/app/integrations"
"github.com/SigNoz/signoz/pkg/query-service/app/logparsingpipeline"
"github.com/SigNoz/signoz/pkg/query-service/app/opamp"
opAmpModel "github.com/SigNoz/signoz/pkg/query-service/app/opamp/model"
baseconst "github.com/SigNoz/signoz/pkg/query-service/constants"
"github.com/SigNoz/signoz/pkg/query-service/healthcheck"
"github.com/SigNoz/signoz/pkg/query-service/utils"
)
// Server runs auxiliary servers (opamp) alongside the signoz apiserver
// Server runs HTTP, Mux and a grpc server
type Server struct {
config signoz.Config
signoz *signoz.SigNoz
// public http router
httpConn net.Listener
httpServer *http.Server
httpHostPort string
opampServer *opamp.Server
// Usage manager
usageManager *usage.Manager
unavailableChannel chan healthcheck.Status
}
// NewServer creates and initializes Server
@@ -100,11 +127,57 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
return nil, err
}
// Register the legacy query-service routes on the apiserver router. The
// apiserver owns the HTTP server and applies the middleware chain at serve
// time, so these routes get the same treatment as the apiserver routes.
r := signoz.APIServer.Router()
am := middleware.NewAuthZ(signoz.Instrumentation.Logger(), signoz.Modules.OrgGetter, signoz.Authz)
s := &Server{
config: config,
signoz: signoz,
httpHostPort: baseconst.HTTPHostPort,
unavailableChannel: make(chan healthcheck.Status),
usageManager: usageManager,
}
httpServer, err := s.createPublicServer(apiHandler, signoz.Web)
if err != nil {
return nil, err
}
s.httpServer = httpServer
s.opampServer = opamp.InitializeServer(
&opAmpModel.AllAgents, agentConfMgr, signoz.Instrumentation,
)
return s, nil
}
// HealthCheckStatus returns health check status channel a client can subscribe to
func (s Server) HealthCheckStatus() chan healthcheck.Status {
return s.unavailableChannel
}
func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*http.Server, error) {
r := baseapp.NewRouter()
am := middleware.NewAuthZ(s.signoz.Instrumentation.Logger(), s.signoz.Modules.OrgGetter, s.signoz.Authz)
r.Use(middleware.NewRecovery(s.signoz.Instrumentation.Logger()).Wrap)
r.Use(otelmux.Middleware(
"apiserver",
otelmux.WithMeterProvider(s.signoz.Instrumentation.MeterProvider()),
otelmux.WithTracerProvider(s.signoz.Instrumentation.TracerProvider()),
otelmux.WithPropagators(propagation.NewCompositeTextMapPropagator(propagation.Baggage{}, propagation.TraceContext{})),
otelmux.WithFilter(func(r *http.Request) bool {
return !slices.Contains([]string{"/api/v1/health"}, r.URL.Path)
}),
))
r.Use(middleware.NewIdentN(s.signoz.IdentNResolver, s.signoz.Sharder, s.signoz.Instrumentation.Logger()).Wrap)
r.Use(middleware.NewTimeout(s.signoz.Instrumentation.Logger(),
s.config.APIServer.Timeout.ExcludedRoutes,
s.config.APIServer.Timeout.Default,
s.config.APIServer.Timeout.Max,
).Wrap)
r.Use(middleware.NewResource(s.signoz.Instrumentation.Logger()).Wrap)
r.Use(middleware.NewAudit(s.signoz.Instrumentation.Logger(), s.config.APIServer.Logging.ExcludedRoutes, s.signoz.Auditor).Wrap)
r.Use(middleware.NewComment().Wrap)
apiHandler.RegisterRoutes(r, am)
apiHandler.RegisterLogsRoutes(r, am)
@@ -115,29 +188,107 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
apiHandler.RegisterThirdPartyApiRoutes(r, am)
apiHandler.RegisterTraceFunnelsRoutes(r, am)
s := &Server{
usageManager: usageManager,
err := s.signoz.APIServer.AddToRouter(r)
if err != nil {
return nil, err
}
s.opampServer = opamp.InitializeServer(
&opAmpModel.AllAgents, agentConfMgr, signoz.Instrumentation,
)
c := cors.New(cors.Options{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "DELETE", "POST", "PUT", "PATCH", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "cache-control"},
})
return s, nil
handler := c.Handler(r)
handler = handlers.CompressHandler(handler)
err = web.AddToRouter(r)
if err != nil {
return nil, err
}
routePrefix := s.config.Global.ExternalPath()
if routePrefix != "" {
prefixed := http.StripPrefix(routePrefix, handler)
handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
switch req.URL.Path {
case "/api/v1/health", "/api/v2/healthz", "/api/v2/readyz", "/api/v2/livez":
r.ServeHTTP(w, req)
return
}
prefixed.ServeHTTP(w, req)
})
}
return &http.Server{
Handler: handler,
}, nil
}
// Start starts the opamp websocket server. The HTTP API server is started by
// the signoz registry.
func (s *Server) Start(ctx context.Context) error {
slog.Info("Starting OpAmp Websocket server", "addr", baseconst.OpAmpWsEndpoint)
if err := s.opampServer.Start(baseconst.OpAmpWsEndpoint); err != nil {
// initListeners initialises listeners of the server
func (s *Server) initListeners() error {
// listen on public port
var err error
publicHostPort := s.httpHostPort
if publicHostPort == "" {
return fmt.Errorf("baseconst.HTTPHostPort is required")
}
s.httpConn, err = net.Listen("tcp", publicHostPort)
if err != nil {
return err
}
slog.Info(fmt.Sprintf("Query server started listening on %s...", s.httpHostPort))
return nil
}
// Start listening on http and private http port concurrently
func (s *Server) Start(ctx context.Context) error {
err := s.initListeners()
if err != nil {
return err
}
var httpPort int
if port, err := utils.GetPort(s.httpConn.Addr()); err == nil {
httpPort = port
}
go func() {
slog.Info("Starting HTTP server", "port", httpPort, "addr", s.httpHostPort)
switch err := s.httpServer.Serve(s.httpConn); err {
case nil, http.ErrServerClosed, cmux.ErrListenerClosed:
// normal exit, nothing to do
default:
slog.Error("Could not start HTTP server", errors.Attr(err))
}
s.unavailableChannel <- healthcheck.Unavailable
}()
go func() {
slog.Info("Starting OpAmp Websocket server", "addr", baseconst.OpAmpWsEndpoint)
err := s.opampServer.Start(baseconst.OpAmpWsEndpoint)
if err != nil {
slog.Error("opamp ws server failed to start", errors.Attr(err))
s.unavailableChannel <- healthcheck.Unavailable
}
}()
return nil
}
func (s *Server) Stop(ctx context.Context) error {
if s.httpServer != nil {
if err := s.httpServer.Shutdown(ctx); err != nil {
return err
}
}
s.opampServer.Stop()
// stop usage manager

View File

@@ -140,31 +140,6 @@ func TestManager_TestNotification_SendUnmatched_ThresholdRule(t *testing.T) {
}
}
var gkeyCols = []cmock.ColumnType{
{Name: "gkey", Type: "String"},
{Name: "grid", Type: "Array(Nullable(Float64))"},
}
// lastSampleGrid builds the grid a transpiled instant selector returns: per
// slot t, the latest sample in the left-open lookback window (t-lookback, t].
func lastSampleGrid(tsMs []int64, values []float64, startMs, endMs, stepMs, lookbackMs int64) []*float64 {
grid := make([]*float64, (endMs-startMs)/stepMs+1)
for i := range grid {
slot := startMs + int64(i)*stepMs
best := -1
for j, ts := range tsMs {
if ts > slot-lookbackMs && ts <= slot && (best == -1 || ts >= tsMs[best]) {
best = j
}
}
if best >= 0 {
v := values[best]
grid[i] = &v
}
}
return grid
}
func TestManager_TestNotification_SendUnmatched_PromRule(t *testing.T) {
target := 10.0
@@ -185,7 +160,7 @@ func TestManager_TestNotification_SendUnmatched_PromRule(t *testing.T) {
triggeredTestAlerts := []map[*alertmanagertypes.PostableAlert][]string{}
// Variable to store promProvider for cleanup
var promProvider prometheus.Prometheus
var promProvider *prometheustest.Provider
// Create manager using test factory with hooks
mgr := rules.NewTestManager(t, &rules.TestManagerOptions{
@@ -210,29 +185,76 @@ func TestManager_TestNotification_SendUnmatched_PromRule(t *testing.T) {
TelemetryStoreHook: func(store telemetrystore.TelemetryStore) {
mockStore := store.(*telemetrystoretest.Provider)
// Grid the TestNotification eval computes over (see
// Timestamps on base_rule); nil args match any window.
// Set up Prometheus-specific mock data
// Fingerprint columns for Prometheus queries
fingerprintCols := []cmock.ColumnType{
{Name: "fingerprint", Type: "UInt64"},
{Name: "any(labels)", Type: "String"},
}
// Samples columns for Prometheus queries
samplesCols := []cmock.ColumnType{
{Name: "metric_name", Type: "String"},
{Name: "fingerprint", Type: "UInt64"},
{Name: "unix_milli", Type: "Int64"},
{Name: "value", Type: "Float64"},
{Name: "flags", Type: "UInt32"},
}
// Calculate query time range similar to Prometheus rule tests
// TestNotification uses time.Now().UTC() for evaluation
// We calculate the query window based on current time to match what the actual evaluation will use
evalTime := baseTime
evalWindowMs := int64(5 * 60 * 1000) // 5 minutes in ms
gridEnd := (evalTime.UnixMilli() / 60000) * 60000
gridStart := gridEnd - evalWindowMs
evalTimeMs := evalTime.UnixMilli()
queryStart := ((evalTimeMs-2*evalWindowMs)/60000)*60000 + 1 // truncate to minute + 1ms
queryEnd := (evalTimeMs / 60000) * 60000 // truncate to minute
tsList := make([]int64, 0, len(tc.Values))
vList := make([]float64, 0, len(tc.Values))
// Create fingerprint data
fingerprint := uint64(12345)
labelsJSON := `{"__name__":"test_metric"}`
fingerprintData := [][]interface{}{
{fingerprint, labelsJSON},
}
fingerprintRows := cmock.NewRows(fingerprintCols, fingerprintData)
// Create samples data from test case values, calculating timestamps relative to baseTime
validSamplesData := make([][]interface{}, 0)
for _, v := range tc.Values {
// Skip NaN and Inf values in the samples data
if math.IsNaN(v.Value) || math.IsInf(v.Value, 0) {
continue
}
tsList = append(tsList, baseTime.Add(v.Offset).UnixMilli())
vList = append(vList, v.Value)
// Calculate timestamp relative to baseTime
sampleTimestamp := baseTime.Add(v.Offset).UnixMilli()
validSamplesData = append(validSamplesData, []interface{}{
"test_metric",
fingerprint,
sampleTimestamp,
v.Value,
uint32(0), // flags - 0 means normal value
})
}
grid := lastSampleGrid(tsList, vList, gridStart, gridEnd, 60_000, 300_000)
samplesRows := cmock.NewRows(samplesCols, validSamplesData)
mock := mockStore.Mock()
mock.ExpectQuery("SELECT gkey").
WithArgs("test_metric", nil, nil, "test_metric", nil, nil).
WillReturnRows(cmock.NewRows(gkeyCols, [][]any{{`[["__name__","test_metric"]]`, grid}}))
// Mock the fingerprint query (for Prometheus label matching)
// args: $1=metric_name (the __name__ matcher maps onto the column)
mock.ExpectQuery("SELECT fingerprint, any").
WithArgs("test_metric").
WillReturnRows(fingerprintRows)
// Mock the samples query (for Prometheus metric data)
// args: metric_name IN (discovered names), subquery metric_name, start, end
mock.ExpectQuery("SELECT metric_name, fingerprint, unix_milli").
WithArgs(
"test_metric",
"test_metric",
queryStart,
queryEnd,
).
WillReturnRows(samplesRows)
// Create Prometheus provider for this test
promProvider = prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{Timeout: 2 * time.Minute}, store)
@@ -267,6 +289,7 @@ func TestManager_TestNotification_SendUnmatched_PromRule(t *testing.T) {
assert.Empty(t, triggeredTestAlerts)
}
promProvider.Close()
})
}
}

View File

@@ -614,6 +614,18 @@ export const listViewInitialLogQuery: Query = {
},
};
export const PANEL_TYPES_INITIAL_QUERY: Record<PANEL_TYPES, Query> = {
[PANEL_TYPES.TIME_SERIES]: initialQueriesMap.metrics,
[PANEL_TYPES.VALUE]: initialQueriesMap.metrics,
[PANEL_TYPES.TABLE]: initialQueriesMap.metrics,
[PANEL_TYPES.LIST]: listViewInitialLogQuery,
[PANEL_TYPES.TRACE]: initialQueriesMap.traces,
[PANEL_TYPES.BAR]: initialQueriesMap.metrics,
[PANEL_TYPES.PIE]: initialQueriesMap.metrics,
[PANEL_TYPES.HISTOGRAM]: initialQueriesMap.metrics,
[PANEL_TYPES.EMPTY_WIDGET]: initialQueriesMap.metrics,
};
export const listViewInitialTraceQuery: Query = {
// it should be the above commented query
...initialQueriesMap.traces,

View File

@@ -1,14 +0,0 @@
.container {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.3rem;
margin: 8px 0;
}
.optionsTrigger {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
}

View File

@@ -1,82 +0,0 @@
import { memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Settings } from '@signozhq/icons';
import FieldsSelector from 'components/FieldsSelector';
import Controls, { ControlsProps } from 'container/Controls';
import { OptionsMenuConfig } from 'container/OptionsMenu/types';
import useQueryPagination from 'hooks/queryPagination/useQueryPagination';
import { DataSource } from 'types/common/queryBuilder';
import styles from './Controls.module.scss';
function TraceExplorerControls({
isLoading,
totalCount,
perPageOptions,
config,
showSizeChanger = true,
}: TraceExplorerControlsProps): JSX.Element | null {
const { t } = useTranslation(['trace']);
const [isFieldsSelectorOpen, setIsFieldsSelectorOpen] = useState(false);
const {
pagination,
handleCountItemsPerPageChange,
handleNavigateNext,
handleNavigatePrevious,
} = useQueryPagination(totalCount, perPageOptions);
return (
<div className={styles.container}>
{config?.fieldsSelector && (
<>
<div
className={styles.optionsTrigger}
onClick={(): void => setIsFieldsSelectorOpen(true)}
>
{t('options_menu.options')}
<Settings size="md" />
</div>
<FieldsSelector
isOpen={isFieldsSelectorOpen}
title="Edit columns"
fields={config.fieldsSelector.value}
onFieldsChange={config.fieldsSelector.onFieldsChange}
onClose={(): void => setIsFieldsSelectorOpen(false)}
signal={DataSource.TRACES}
/>
</>
)}
<Controls
isLoading={isLoading}
totalCount={totalCount}
offset={pagination.offset}
countPerPage={pagination.limit}
perPageOptions={perPageOptions}
handleCountItemsPerPageChange={handleCountItemsPerPageChange}
handleNavigateNext={handleNavigateNext}
handleNavigatePrevious={handleNavigatePrevious}
showSizeChanger={showSizeChanger}
/>
</div>
);
}
TraceExplorerControls.defaultProps = {
config: null,
};
type TraceExplorerControlsProps = Pick<
ControlsProps,
'isLoading' | 'totalCount' | 'perPageOptions'
> & {
config?: OptionsMenuConfig | null;
showSizeChanger?: boolean;
};
TraceExplorerControls.defaultProps = {
showSizeChanger: true,
};
export default memo(TraceExplorerControls);

View File

@@ -1,168 +0,0 @@
import { Link } from 'react-router-dom';
import type { TableColumnsType as ColumnsType } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import { Typography } from '@signozhq/ui/typography';
import { TelemetryFieldKey } from 'api/v5/v5';
import type { TracesTableRow } from '../TracesTable/getFieldColumn';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import ROUTES from 'constants/routes';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
import { formUrlParams } from 'container/TraceDetail/utils';
import { TimestampInput } from 'hooks/useTimezoneFormatter/useTimezoneFormatter';
import { RowData } from 'lib/query/createTableColumnsFromQuery';
import LineClampedText from 'periscope/components/LineClampedText/LineClampedText';
import { ILog } from 'types/api/logs/log';
import { QueryDataV3 } from 'types/api/widgets/getQuery';
export function BlockLink({
children,
to,
openInNewTab,
}: {
children: React.ReactNode;
to: string;
openInNewTab: boolean;
}): any {
// Display block to make the whole cell clickable
return (
<Link
to={to}
style={{ display: 'block' }}
target={openInNewTab ? '_blank' : '_self'}
>
{children}
</Link>
);
}
export const transformDataWithDate = (
data: QueryDataV3[],
): Omit<ILog, 'timestamp'>[] =>
data[0]?.list?.map(({ data, timestamp }) => ({ ...data, date: timestamp })) ||
[];
export const getTraceLink = (record: Record<string, unknown>): string => {
function readId(value: unknown): string {
if (typeof value === 'string' || typeof value === 'number') {
return String(value);
}
return '';
}
const traceId = readId(record.traceID) || readId(record.trace_id);
const spanId = readId(record.spanID) || readId(record.span_id);
return `${ROUTES.TRACE}/${traceId}${formUrlParams({
spanId,
levelUp: 0,
levelDown: 0,
})}`;
};
export const getListColumns = (
selectedColumns: TelemetryFieldKey[],
formatTimezoneAdjustedTimestamp: (
input: TimestampInput,
format?: string,
) => string | number,
): ColumnsType<RowData> => {
const initialColumns: ColumnsType<RowData> = [
{
dataIndex: 'date',
key: 'date',
title: 'Timestamp',
width: 145,
render: (value, item): JSX.Element => {
const date =
typeof value === 'string'
? formatTimezoneAdjustedTimestamp(
value,
DATE_TIME_FORMATS.ISO_DATETIME_MS,
)
: formatTimezoneAdjustedTimestamp(
value / 1e6,
DATE_TIME_FORMATS.ISO_DATETIME_MS,
);
return (
<BlockLink to={getTraceLink(item)} openInNewTab={false}>
<Typography.Text>{date}</Typography.Text>
</BlockLink>
);
},
},
];
const columns: ColumnsType<RowData> =
selectedColumns.map((props) => {
const name = props?.name || (props as any)?.key;
const fieldContext = props?.fieldContext || (props as any)?.type;
return {
title: name,
dataIndex: name,
key: buildCompositeKey(name, fieldContext),
width: 145,
render: (value, item): JSX.Element => {
if (value === '') {
return (
<BlockLink to={getTraceLink(item)} openInNewTab={false}>
<Typography data-testid={name}>N/A</Typography>
</BlockLink>
);
}
if (
name === 'httpMethod' ||
name === 'responseStatusCode' ||
name === 'response_status_code' ||
name === 'http_method'
) {
return (
<BlockLink to={getTraceLink(item)} openInNewTab={false}>
<Badge data-testid={name} color="sakura" variant="outline">
{value}
</Badge>
</BlockLink>
);
}
if (name === 'durationNano' || name === 'duration_nano') {
return (
<BlockLink to={getTraceLink(item)} openInNewTab={false}>
<Typography data-testid={name}>{getMs(value)}ms</Typography>
</BlockLink>
);
}
return (
<BlockLink to={getTraceLink(item)} openInNewTab={false}>
<Typography data-testid={name}>
<LineClampedText text={value} lines={3} />
</Typography>
</BlockLink>
);
},
responsive: ['md'],
};
}) || [];
return [...initialColumns, ...columns];
};
// Reshapes the query-range list payload into table rows. `id` mirrors span_id so
// TanStack sees genuine row changes on orderBy toggles instead of falling back to
// positional ids; `timestamp` is lifted from the wrapping ListItem.
export const transformSpanRows = (data: QueryDataV3[]): TracesTableRow[] => {
const list = data[0]?.list;
if (!list) {
return [];
}
return list.map((item) => {
const row = item.data as Record<string, unknown>;
return {
...row,
timestamp: item.timestamp,
id: row.span_id,
};
}) as TracesTableRow[];
};

View File

@@ -1,19 +0,0 @@
.loading-traces {
padding: 24px 0;
height: 240px;
display: flex;
justify-content: center;
align-items: flex-start;
.loading-traces-content {
display: flex;
align-items: flex-start;
flex-direction: column;
.loading-gif {
height: 72px;
margin-left: -24px;
}
}
}

View File

@@ -1,22 +0,0 @@
import { useTranslation } from 'react-i18next';
import { Typography } from '@signozhq/ui/typography';
import { DataSource } from 'types/common/queryBuilder';
import loadingPlaneUrl from '@/assets/Icons/loading-plane.gif';
import './TraceLoading.styles.scss';
export function TracesLoading(): JSX.Element {
const { t } = useTranslation('common');
return (
<div className="loading-traces">
<div className="loading-traces-content">
<img className="loading-gif" src={loadingPlaneUrl} alt="wait-icon" />
<Typography>
{t('pending_data_placeholder', { dataSource: DataSource.TRACES })}
</Typography>
</div>
</div>
);
}

View File

@@ -1,77 +0,0 @@
import { generatePath, Link } from 'react-router-dom';
import { Badge } from '@signozhq/ui/badge';
import TanStackTable from 'components/TanStackTableView';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import ROUTES from 'constants/routes';
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
import { useTimezone } from 'providers/Timezone';
import {
DURATION_FIELD_NAMES,
STATUS_FIELD_NAMES,
TIMESTAMP_FIELD_NAMES,
TRACE_ID_FIELD_NAMES,
} from './constants';
import { stringifyCellValue } from './utils';
type FieldCellProps = {
name: string;
value: unknown;
};
function FieldCell({ name, value }: FieldCellProps): JSX.Element {
const { formatTimezoneAdjustedTimestamp } = useTimezone();
if (TIMESTAMP_FIELD_NAMES.has(name)) {
const ts = value as string | number;
const formatted =
typeof ts === 'string'
? formatTimezoneAdjustedTimestamp(ts, DATE_TIME_FORMATS.ISO_DATETIME_MS)
: formatTimezoneAdjustedTimestamp(
ts / 1e6,
DATE_TIME_FORMATS.ISO_DATETIME_MS,
);
const text = String(formatted);
return <TanStackTable.Text title={text}>{text}</TanStackTable.Text>;
}
if (value === '' || value == null) {
return <TanStackTable.Text data-testid={name}>-</TanStackTable.Text>;
}
const text = stringifyCellValue(value);
if (TRACE_ID_FIELD_NAMES.has(name)) {
return (
<Link
to={generatePath(ROUTES.TRACE_DETAIL, { id: text })}
data-testid="trace-id"
onClick={(e): void => e.stopPropagation()}
>
{text}
</Link>
);
}
if (STATUS_FIELD_NAMES.has(name)) {
return (
<Badge data-testid={name} color="sakura" variant="outline">
{text}
</Badge>
);
}
if (DURATION_FIELD_NAMES.has(name)) {
return (
<TanStackTable.Text data-testid={name}>{getMs(text)}ms</TanStackTable.Text>
);
}
return (
<TanStackTable.Text data-testid={name} title={text}>
{text}
</TanStackTable.Text>
);
}
export default FieldCell;

View File

@@ -1,26 +0,0 @@
.tableWrapper {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.tracesTable {
--tanstack-table-row-height: 54px;
--tanstack-table-header-height: 54px;
--tanstack-cell-padding-top-override: 5px;
--tanstack-cell-padding-bottom-override: 5px;
--tanstack-cell-padding-right-override: 15px;
--tanstack-cell-padding-left-override: 15px;
--tanstack-cell-header-padding-left-override: 5px;
--tanstack-cell-header-padding-left-first-column: 15px;
--tanstack-plain-body-line-clamp: 1;
--tanstack-table-cell-bg: var(--l2-background);
--tanstack-table-header-cell-bg: var(--l1-background-hover);
--tanstack-table-row-hover-bg: var(--l1-background-hover);
}

View File

@@ -1,116 +0,0 @@
import { useCallback } from 'react';
import { useHistory } from 'react-router-dom';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import TanStackTable from 'components/TanStackTableView';
import type {
CellTypographySize,
TableColumnDef,
} from 'components/TanStackTableView/types';
import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
import NoLogs from 'container/NoLogs/NoLogs';
import { TracesLoading } from '../TraceLoading/TraceLoading';
import APIError from 'types/api/error';
import { DataSource, PanelTypeKeys } from 'types/common/queryBuilder';
import { getAbsoluteUrl } from 'utils/basePath';
import type { TracesTableRow } from './getFieldColumn';
import styles from './TracesTable.module.scss';
export type TracesTableProps = {
data: TracesTableRow[];
columns: TableColumnDef<TracesTableRow>[];
columnStorageKey?: string;
respectColumnOrder?: boolean;
panelType: PanelTypeKeys;
/** Builds the trace-detail href for a row; drives row click + cmd/ctrl-click. */
getRowHref: (row: TracesTableRow) => string;
isLoading: boolean;
isFetching: boolean;
isError: boolean;
error: APIError | Error | null;
isFilterApplied: boolean;
onColumnOrderChange?: (cols: TableColumnDef<TracesTableRow>[]) => void;
onColumnRemove?: (columnId: string) => void;
cellTypographySize?: CellTypographySize;
};
function TracesTable({
data,
columns,
columnStorageKey,
respectColumnOrder = false,
panelType,
getRowHref,
isLoading,
isFetching,
isError,
error,
isFilterApplied,
onColumnOrderChange,
onColumnRemove,
cellTypographySize = 'medium',
}: TracesTableProps): JSX.Element {
const history = useHistory();
const isDataAbsent =
!isLoading && !isFetching && !isError && data.length === 0;
const handleRowClick = useCallback(
(row: TracesTableRow): void => {
history.push(getRowHref(row));
},
[history, getRowHref],
);
const handleRowClickNewTab = useCallback(
(row: TracesTableRow): void => {
window.open(getAbsoluteUrl(getRowHref(row)), '_blank', 'noopener');
},
[getRowHref],
);
return (
<>
{isError && error && <ErrorInPlace error={error as APIError} />}
{(isLoading || (isFetching && data.length === 0)) && <TracesLoading />}
{isDataAbsent && !isFilterApplied && (
<NoLogs dataSource={DataSource.TRACES} />
)}
{isDataAbsent && isFilterApplied && (
<EmptyLogsSearch dataSource={DataSource.TRACES} panelType={panelType} />
)}
{!isError && data.length !== 0 && (
<div className={styles.tableWrapper}>
<TanStackTable<TracesTableRow>
data={data}
columns={columns}
className={styles.tracesTable}
columnStorageKey={columnStorageKey}
respectColumnOrder={respectColumnOrder}
isLoading={isFetching}
cellTypographySize={cellTypographySize}
onColumnOrderChange={onColumnOrderChange}
onColumnRemove={onColumnRemove}
onRowClick={handleRowClick}
onRowClickNewTab={handleRowClickNewTab}
getRowTestId={(row): string => `traces-table-row-${row.id}`}
/>
</div>
)}
</>
);
}
TracesTable.defaultProps = {
columnStorageKey: undefined,
respectColumnOrder: false,
onColumnOrderChange: undefined,
onColumnRemove: undefined,
cellTypographySize: 'medium',
};
export default TracesTable;

View File

@@ -1,18 +0,0 @@
// Field-name allowlists that drive signal-specific cell rendering. Both legacy
// camelCase and snake_case variants are listed because the API has shipped both.
export const TIMESTAMP_FIELD_NAMES = new Set(['timestamp']);
export const STATUS_FIELD_NAMES = new Set([
'httpMethod',
'http_method',
'http.method',
'http.request.method',
'responseStatusCode',
'response_status_code',
'http.status_code',
'http.response.status_code',
]);
export const DURATION_FIELD_NAMES = new Set(['durationNano', 'duration_nano']);
export const TRACE_ID_FIELD_NAMES = new Set(['traceID', 'trace_id']);

View File

@@ -1,26 +0,0 @@
import { TelemetryFieldKey } from 'api/v5/v5';
import type { TableColumnDef } from 'components/TanStackTableView/types';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import { TIMESTAMP_FIELD_NAMES } from './constants';
import FieldCell from './FieldCell';
export type TracesTableRow = { id: string } & Record<string, unknown>;
export function getFieldColumn(
field: TelemetryFieldKey,
): TableColumnDef<TracesTableRow> {
const { name, fieldContext, fieldDataType } = field;
const isTimestamp = TIMESTAMP_FIELD_NAMES.has(name);
return {
id: buildCompositeKey(name, fieldContext, fieldDataType),
header: name,
accessorFn: (row): unknown => row[name],
enableMove: !isTimestamp,
enableRemove: !isTimestamp,
canBeHidden: !isTimestamp,
width: { min: 192 },
cell: ({ value }): JSX.Element => <FieldCell name={name} value={value} />,
};
}

View File

@@ -1,12 +0,0 @@
export function stringifyCellValue(value: unknown): string {
if (value == null) {
return '';
}
if (typeof value === 'string') {
return value;
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value);
}
return JSON.stringify(value);
}

View File

@@ -1,6 +1,9 @@
import { TelemetryFieldKey } from 'api/v5/v5';
import type { TableColumnDef } from 'components/TanStackTableView/types';
import { getFieldColumn, TracesTableRow } from '../TracesTable/getFieldColumn';
import {
getFieldColumn,
TracesTableRow,
} from 'container/TracesExplorer/TracesTable/getFieldColumn';
import { DEFAULT_PER_PAGE_OPTIONS } from 'hooks/queryPagination';
export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS];

View File

@@ -1,235 +0,0 @@
/**
* AI Assistant page-action factories for the Traces Explorer.
*
* Mirrors the logs equivalents — each factory closes over live page
* state/callbacks so `execute()` always operates on the current query, and
* the page component instantiates them via `useMemo` + `usePageActions`.
*
* See `pages/LogsExplorer/aiActions.ts` for the rationale behind writing
* BOTH `filters.items` and `filter.expression` and then re-using the same
* URL parser shape via `redirectWithQueryBuilderData`.
*/
import { convertFiltersToExpression } from 'components/QueryBuilderV2/utils';
import {
aiFilterToTagFilterItem,
FILTER_OP_ENUM,
FILTER_VALUE_DESCRIPTION,
FilterDeps,
replaceFirstQueryData,
} from 'container/AIAssistant/pageActions/builderQueryHelpers';
import {
ActionResult,
PageAction,
} from 'container/AIAssistant/pageActions/types';
import {
IBuilderQuery,
TagFilterItem,
} from 'types/api/queryBuilder/queryBuilderData';
interface AIFilter {
key: string;
op: string;
value: string;
}
interface RunQueryParams {
filters: AIFilter[];
}
interface AddFilterParams {
key: string;
op: string;
value: string;
}
type TracesView = 'list' | 'timeseries' | 'table' | 'trace';
interface ChangeViewParams {
view: TracesView;
}
interface SaveViewParams {
name: string;
}
/**
* Replace all active span filters and navigate to the updated query URL
* (which makes the WHERE clause reflect the new filters and triggers a re-run).
*/
export function tracesRunQueryAction(
deps: FilterDeps,
): PageAction<RunQueryParams> {
return {
id: 'traces.runQuery',
description: 'Replace the active trace filters and re-run the query',
parameters: {
type: 'object',
properties: {
filters: {
type: 'array',
description: 'Replacement filter list',
items: {
type: 'object',
properties: {
key: {
type: 'string',
description: 'Attribute key, e.g. service.name, http.status_code',
},
op: {
type: 'string',
enum: [...FILTER_OP_ENUM],
},
value: {
type: 'string',
description: FILTER_VALUE_DESCRIPTION,
},
},
required: ['key', 'op', 'value'],
},
},
},
required: ['filters'],
},
autoApply: true,
execute: async ({ filters }): Promise<ActionResult> => {
const baseQuery = deps.currentQuery.builder.queryData[0];
if (!baseQuery) {
throw new Error('No active query found in Traces Explorer.');
}
const tagItems = filters.map(aiFilterToTagFilterItem);
const newFilters = { items: tagItems, op: 'AND' };
const updatedBuilderQuery: IBuilderQuery = {
...baseQuery,
filters: newFilters,
filter: convertFiltersToExpression(newFilters),
};
deps.handleSetQueryData(0, updatedBuilderQuery);
deps.redirectWithQueryBuilderData(
replaceFirstQueryData(deps.currentQuery, updatedBuilderQuery),
);
return {
summary: `Query updated with ${filters.length} filter(s) and re-run.`,
};
},
getContext: (): Record<string, unknown> => ({
filters:
deps.currentQuery.builder.queryData[0]?.filters?.items?.map(
(f: TagFilterItem) => ({
key: f.key?.key,
op: f.op,
value: f.value,
}),
) ?? [],
}),
};
}
/**
* Append a single filter to the existing trace query and navigate to the
* updated URL.
*/
export function tracesAddFilterAction(
deps: FilterDeps,
): PageAction<AddFilterParams> {
return {
id: 'traces.addFilter',
description: 'Add a single filter to the current trace query and re-run',
parameters: {
type: 'object',
properties: {
key: {
type: 'string',
description: 'Attribute key, e.g. service.name, http.status_code',
},
op: {
type: 'string',
enum: [...FILTER_OP_ENUM],
},
value: {
type: 'string',
description: FILTER_VALUE_DESCRIPTION,
},
},
required: ['key', 'op', 'value'],
},
autoApply: true,
execute: async ({ key, op, value }): Promise<ActionResult> => {
const baseQuery = deps.currentQuery.builder.queryData[0];
if (!baseQuery) {
throw new Error('No active query found in Traces Explorer.');
}
const existing = baseQuery.filters?.items ?? [];
const newItem = aiFilterToTagFilterItem({ key, op, value });
const newFilters = { items: [...existing, newItem], op: 'AND' };
const updatedBuilderQuery: IBuilderQuery = {
...baseQuery,
filters: newFilters,
filter: convertFiltersToExpression(newFilters),
};
deps.handleSetQueryData(0, updatedBuilderQuery);
deps.redirectWithQueryBuilderData(
replaceFirstQueryData(deps.currentQuery, updatedBuilderQuery),
);
return { summary: `Filter added: ${key} ${op} "${value}". Query re-run.` };
},
};
}
/**
* Switch the traces explorer between list / timeseries / table / trace views.
*/
export function tracesChangeViewAction(deps: {
onChangeView: (view: TracesView) => void;
}): PageAction<ChangeViewParams> {
return {
id: 'traces.changeView',
description:
'Switch the Traces Explorer between list, timeseries, table, and trace views',
parameters: {
type: 'object',
properties: {
view: {
type: 'string',
enum: ['list', 'timeseries', 'table', 'trace'],
description: 'The panel view to switch to',
},
},
required: ['view'],
},
execute: async ({ view }): Promise<ActionResult> => {
deps.onChangeView(view);
return { summary: `Switched to the "${view}" view.` };
},
};
}
/**
* Save the current trace query as a named view (stub — wires to real API
* when available).
*/
export function tracesSaveViewAction(deps: {
onSaveView: (name: string) => Promise<void>;
}): PageAction<SaveViewParams> {
return {
id: 'traces.saveView',
description: 'Save the current trace query as a named view',
parameters: {
type: 'object',
properties: {
name: { type: 'string', description: 'Name for the saved view' },
},
required: ['name'],
},
execute: async ({ name }): Promise<ActionResult> => {
await deps.onSaveView(name);
return { summary: `View "${name}" saved.` };
},
};
}

View File

@@ -1,132 +0,0 @@
import {
ArrowUpToLine,
Atom,
Filter,
SquareMousePointer,
Terminal,
Binoculars,
} from '@signozhq/icons';
import { Button, Tooltip } from 'antd';
import cx from 'classnames';
import { ExplorerViews } from 'pages/LogsExplorer/utils';
import './ToolbarActions.styles.scss';
interface LeftToolbarActionsProps {
items: any;
selectedView: string;
onChangeSelectedView: (view: ExplorerViews) => void;
showFilter: boolean;
handleFilterVisibilityChange: () => void;
}
const activeTab = 'active-tab';
export default function LeftToolbarActions({
items,
selectedView,
onChangeSelectedView,
showFilter,
handleFilterVisibilityChange,
}: LeftToolbarActionsProps): JSX.Element {
const { clickhouse, list, timeseries, table, trace } = items;
return (
<div className="left-toolbar">
{!showFilter && (
<Tooltip title="Show Filters">
<Button onClick={handleFilterVisibilityChange} className="filter-btn">
<Filter size={12} />
<ArrowUpToLine size={12} style={{ transform: 'rotate(90deg)' }} />
</Button>
</Tooltip>
)}
<div className="left-toolbar-query-actions">
{list?.show && (
<Tooltip title="List View">
<Button
disabled={list.disabled}
className={cx(
'list-view-tab',
'explorer-view-option',
selectedView === list.key ? activeTab : '',
)}
onClick={(): void => onChangeSelectedView(list.key)}
>
<SquareMousePointer size={14} data-testid="search-view" />
List View
</Button>
</Tooltip>
)}
{trace?.show && (
<Tooltip title="Trace View">
<Button
disabled={trace.disabled}
className={cx(
'trace-view-tab',
'explorer-view-option',
selectedView === trace.key ? activeTab : '',
)}
onClick={(): void => onChangeSelectedView(trace.key)}
>
<SquareMousePointer size={14} data-testid="trace-view" />
Trace View
</Button>
</Tooltip>
)}
{timeseries?.show && (
<Tooltip title="Time Series">
<Button
disabled={timeseries.disabled}
className={cx(
'timeseries-view-tab',
'explorer-view-option',
selectedView === timeseries.key ? activeTab : '',
)}
onClick={(): void => onChangeSelectedView(timeseries.key)}
>
<Atom size={14} data-testid="query-builder-view" />
Time Series
</Button>
</Tooltip>
)}
{clickhouse?.show && (
<Tooltip title="Clickhouse">
<Button
disabled={clickhouse.disabled}
className={cx(
'clickhouse-view-tab',
'explorer-view-option',
selectedView === clickhouse.key ? activeTab : '',
)}
onClick={(): void => onChangeSelectedView(clickhouse.key)}
>
<Terminal size={14} data-testid="clickhouse-view" />
Clickhouse
</Button>
</Tooltip>
)}
{table?.show && (
<Tooltip title="Table">
<Button
disabled={table.disabled}
className={cx(
'table-view-tab',
'explorer-view-option',
selectedView === table.key ? activeTab : '',
)}
onClick={(): void => onChangeSelectedView(table.key)}
>
<Binoculars size={14} data-testid="query-builder-view-v2" />
Table
</Button>
</Tooltip>
)}
</div>
</div>
);
}

View File

@@ -1,125 +0,0 @@
.left-toolbar {
display: flex;
align-items: center;
.filter-btn {
display: flex;
align-items: center;
justify-content: center;
box-shadow: none;
height: 32px;
margin-right: 12px;
border: 1px solid var(--l1-border);
}
.left-toolbar-query-actions {
display: flex;
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l1-background);
flex-direction: row;
border-bottom: none;
margin-bottom: -1px;
.prom-ql-icon {
height: 14px;
width: 14px;
}
.explorer-view-option {
display: flex;
align-items: center;
justify-content: center;
flex-direction: row;
border: none;
padding: 9px;
box-shadow: none;
border-radius: 0px;
border-left: 1px solid var(--l1-border);
border-bottom: 1px solid var(--l1-border);
gap: 8px;
&.active-tab {
background-color: var(--primary-background);
border-bottom: 1px solid var(--primary-background);
color: var(--primary-foreground);
&:hover {
background-color: var(--primary-background) !important;
}
}
&:disabled {
background-color: var(--l3-background);
opacity: 0.6;
}
&:first-child {
border-left: 1px solid transparent;
}
&:hover {
background-color: transparent !important;
border-left: 1px solid transparent !important;
color: var(--l1-foreground);
}
}
}
.frequency-chart-view-controller {
display: flex;
align-items: center;
padding-left: 8px;
gap: 8px;
}
}
.right-toolbar {
display: flex;
align-items: center;
background-color: var(--bg-robin-600);
}
.right-actions {
display: flex;
align-items: center;
}
.loading-container {
display: flex;
gap: 8px;
align-items: center;
.loading-btn {
display: flex;
width: 32px;
height: 33px;
padding: 4px 10px;
justify-content: center;
align-items: center;
gap: 6px;
flex-shrink: 0;
border-radius: 2px;
background: var(--l3-background);
box-shadow: none;
border: none;
}
.cancel-run {
display: flex;
height: 33px;
padding: 4px 10px;
justify-content: center;
align-items: center;
gap: 6px;
flex: 1 0 0;
border-radius: 2px;
background: var(--danger-background);
border: none;
}
.cancel-run:hover {
background-color: var(--bg-cherry-400) !important;
color: var(--l1-foreground) !important;
}
}

View File

@@ -767,15 +767,10 @@ export function QueryBuilderProvider({
queryItem.dataSource
].builder.queryData;
// `dataSource` travels with the panel type's fields, but is appended to a
// copy: `propsRequired` is the list held in
// `panelTypeDataSourceFormValuesMap`, and pushing onto it grew that
// module-level array by one entry on every call.
if (propsRequired) {
[...propsRequired, 'dataSource'].forEach((p: any) => {
set(queryItem, p, get(newQueryItem, p));
});
}
propsRequired?.push('dataSource');
propsRequired?.forEach((p: any) => {
set(queryItem, p, get(newQueryItem, p));
});
return queryItem;
}

View File

@@ -211,11 +211,13 @@ export enum QueryFunctionsTypes {
FILL_ZERO = 'fillZero',
}
/**
* Key names of {@link PANEL_TYPES}. Derived rather than listed: the hand-written
* version had fallen behind the enum by three members (`BAR`, `PIE`, `HISTOGRAM`).
*/
export type PanelTypeKeys = keyof typeof PANEL_TYPES;
export type PanelTypeKeys =
| 'TIME_SERIES'
| 'VALUE'
| 'TABLE'
| 'LIST'
| 'TRACE'
| 'EMPTY_WIDGET';
export enum ReduceOperators {
LAST = 'last',

1
go.mod
View File

@@ -57,6 +57,7 @@ require (
github.com/segmentio/analytics-go/v3 v3.2.1
github.com/sethvargo/go-password v0.2.0
github.com/smartystreets/goconvey v1.8.1
github.com/soheilhy/cmux v0.1.5
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1
github.com/swaggest/jsonschema-go v0.3.78

3
go.sum
View File

@@ -1057,6 +1057,8 @@ github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY=
github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60=
github.com/soheilhy/cmux v0.1.5 h1:jjzc5WVemNEDTLwv9tlmemhC73tI08BNOIGwBOo10Js=
github.com/soheilhy/cmux v0.1.5/go.mod h1:T7TcVDs9LWfQgPlPsdngu6I6QIoyIFZDDC6sNE1GqG0=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
@@ -1491,6 +1493,7 @@ golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81R
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201202161906-c7110b5ffcbb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=

View File

@@ -1,14 +1,10 @@
package apiserver
import (
"github.com/SigNoz/signoz/pkg/factory"
"github.com/gorilla/mux"
)
type APIServer interface {
// APIServer is a long running service serving the SigNoz API.
factory.ServiceWithHealthy
// Returns the mux router for the API server. Primarily used for collecting OpenAPI operations.
Router() *mux.Router

View File

@@ -3,16 +3,13 @@ package apiserver
import (
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
httpserver "github.com/SigNoz/signoz/pkg/http/server"
)
// Config holds the configuration for config.
type Config struct {
httpserver.Config `mapstructure:",squash" yaml:",squash"`
Timeout Timeout `mapstructure:"timeout"`
Logging Logging `mapstructure:"logging"`
Timeout Timeout `mapstructure:"timeout"`
Logging Logging `mapstructure:"logging"`
}
type Timeout struct {
@@ -35,10 +32,6 @@ func NewConfigFactory() factory.ConfigFactory {
func newConfig() factory.Config {
return &Config{
Config: httpserver.Config{
Address: "0.0.0.0:8080",
ReadTimeout: 60 * time.Second,
},
Timeout: Timeout{
Default: 60 * time.Second,
Max: 600 * time.Second,
@@ -59,9 +52,5 @@ func newConfig() factory.Config {
}
func (c Config) Validate() error {
if c.Address == "" {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "apiserver.address is required")
}
return nil
}

View File

@@ -8,14 +8,11 @@ import (
"github.com/SigNoz/signoz/pkg/config"
"github.com/SigNoz/signoz/pkg/config/envprovider"
"github.com/SigNoz/signoz/pkg/factory"
httpserver "github.com/SigNoz/signoz/pkg/http/server"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewWithEnvProvider(t *testing.T) {
t.Setenv("SIGNOZ_APISERVER_ADDRESS", "0.0.0.0:9090")
t.Setenv("SIGNOZ_APISERVER_READ__TIMEOUT", "80s")
t.Setenv("SIGNOZ_APISERVER_TIMEOUT_DEFAULT", "70s")
t.Setenv("SIGNOZ_APISERVER_TIMEOUT_MAX", "700s")
t.Setenv("SIGNOZ_APISERVER_TIMEOUT_EXCLUDED__ROUTES", "/excluded1,/excluded2")
@@ -41,10 +38,6 @@ func TestNewWithEnvProvider(t *testing.T) {
require.NoError(t, err)
expected := &Config{
Config: httpserver.Config{
Address: "0.0.0.0:9090",
ReadTimeout: 80 * time.Second,
},
Timeout: Timeout{
Default: 70 * time.Second,
Max: 700 * time.Second,

View File

@@ -2,11 +2,9 @@ package signozapiserver
import (
"context"
"net/http"
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/apiserver"
"github.com/SigNoz/signoz/pkg/auditor"
"github.com/SigNoz/signoz/pkg/authz"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/flagger"
@@ -14,8 +12,6 @@ import (
"github.com/SigNoz/signoz/pkg/global"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/http/middleware"
httpserver "github.com/SigNoz/signoz/pkg/http/server"
"github.com/SigNoz/signoz/pkg/identn"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/modules/aiobservability"
"github.com/SigNoz/signoz/pkg/modules/authdomain"
@@ -41,22 +37,18 @@ import (
"github.com/SigNoz/signoz/pkg/prometheus"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/ruler"
"github.com/SigNoz/signoz/pkg/sharder"
"github.com/SigNoz/signoz/pkg/statsreporter"
"github.com/SigNoz/signoz/pkg/subscription"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/web"
"github.com/SigNoz/signoz/pkg/zeus"
"github.com/gorilla/mux"
)
type provider struct {
globalConfig global.Config
web web.Web
config apiserver.Config
settings factory.ScopedProviderSettings
router *mux.Router
httpServer *httpserver.Server
healthyC chan struct{}
authzMiddleware *middleware.AuthZ
authzService authz.AuthZ
orgHandler organization.Handler
@@ -140,11 +132,6 @@ func NewFactory(
rulerHandler ruler.Handler,
statsHandler statsreporter.Handler,
savedViewHandler savedview.Handler,
globalConfig global.Config,
identNResolver identn.IdentNResolver,
sharder sharder.Sharder,
auditor auditor.Auditor,
web web.Web,
quickFilterModule quickfilter.Module,
quickFilterHandler quickfilter.Handler,
) factory.ProviderFactory[apiserver.APIServer, apiserver.Config] {
@@ -192,11 +179,6 @@ func NewFactory(
rulerHandler,
statsHandler,
savedViewHandler,
globalConfig,
identNResolver,
sharder,
auditor,
web,
quickFilterModule,
quickFilterHandler,
)
@@ -246,11 +228,6 @@ func newProvider(
rulerHandler ruler.Handler,
statsHandler statsreporter.Handler,
savedViewHandler savedview.Handler,
globalConfig global.Config,
identNResolver identn.IdentNResolver,
sharder sharder.Sharder,
auditor auditor.Auditor,
web web.Web,
quickFilterModule quickfilter.Module,
quickFilterHandler quickfilter.Handler,
) (apiserver.APIServer, error) {
@@ -258,10 +235,9 @@ func newProvider(
router := mux.NewRouter().UseEncodedPath()
provider := &provider{
globalConfig: globalConfig,
web: web,
config: config,
settings: settings,
router: router,
healthyC: make(chan struct{}),
orgHandler: orgHandler,
userHandler: userHandler,
authzService: authzService,
@@ -306,68 +282,13 @@ func newProvider(
provider.authzMiddleware = middleware.NewAuthZ(settings.Logger(), orgGetter, authzService)
router.Use(middleware.NewRecovery(settings.Logger()).Wrap)
router.Use(middleware.NewOtel("apiserver", providerSettings.MeterProvider, providerSettings.TracerProvider).Wrap)
router.Use(middleware.NewIdentN(identNResolver, sharder, settings.Logger()).Wrap)
router.Use(middleware.NewTimeout(settings.Logger(),
config.Timeout.ExcludedRoutes,
config.Timeout.Default,
config.Timeout.Max,
).Wrap)
router.Use(middleware.NewResource(settings.Logger()).Wrap)
router.Use(middleware.NewAudit(settings.Logger(), config.Logging.ExcludedRoutes, auditor).Wrap)
router.Use(middleware.NewComment().Wrap)
if err := provider.AddToRouter(router); err != nil {
return nil, err
}
httpHandler := middleware.NewCors().Wrap(router)
httpHandler = middleware.NewCompress().Wrap(httpHandler)
routePrefix := globalConfig.ExternalPath()
if routePrefix != "" {
prefixed := http.StripPrefix(routePrefix, httpHandler)
httpHandler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
switch req.URL.Path {
case "/api/v1/health", "/api/v2/healthz", "/api/v2/readyz", "/api/v2/livez":
router.ServeHTTP(w, req)
return
}
prefixed.ServeHTTP(w, req)
})
}
httpServer, err := httpserver.New(settings.Logger(), config.Config, httpHandler)
if err != nil {
return nil, err
}
provider.httpServer = httpServer
return provider, nil
}
func (provider *provider) Start(ctx context.Context) error {
// Mount the web routes last so the catch-all prefix does not shadow API
// routes registered on the router after construction.
if err := provider.web.AddToRouter(provider.router); err != nil {
return err
}
close(provider.healthyC)
return provider.httpServer.Start(ctx)
}
func (provider *provider) Stop(ctx context.Context) error {
return provider.httpServer.Stop(ctx)
}
func (provider *provider) Healthy() <-chan struct{} {
return provider.healthyC
}
func (provider *provider) Router() *mux.Router {
return provider.router
}

View File

@@ -78,40 +78,6 @@ func NewRegistry(ctx context.Context, logger *slog.Logger, services ...NamedServ
}, nil
}
// Add registers additional services into the registry. It must be called before Start.
func (registry *Registry) Add(ctx context.Context, services ...NamedService) error {
added := make([]*serviceWithState, 0, len(services))
for _, s := range services {
if _, ok := registry.servicesByName[s.Name()]; ok {
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidRegistry, "cannot add service, duplicate service name %q", s.Name())
}
added = append(added, newServiceWithState(s))
}
for _, ss := range added {
registry.services = append(registry.services, ss)
registry.servicesByName[ss.service.Name()] = ss
}
for _, ss := range added {
for _, dep := range ss.service.DependsOn() {
if dep == ss.service.Name() {
registry.logger.ErrorContext(ctx, "ignoring self-dependency", slog.Any("service", ss.service.Name()))
continue
}
if _, ok := registry.servicesByName[dep]; !ok {
registry.logger.ErrorContext(ctx, "ignoring unknown dependency", slog.Any("service", ss.service.Name()), slog.Any("dependency", dep))
continue
}
ss.dependsOn = append(ss.dependsOn, dep)
}
}
return detectCyclicDeps(registry.services)
}
func (registry *Registry) Start(ctx context.Context) {
for _, ss := range registry.services {
go func(ss *serviceWithState) {

View File

@@ -342,61 +342,3 @@ func TestDependsOnCycleReturnsError(t *testing.T) {
assert.Error(t, err)
assert.Contains(t, err.Error(), "dependency cycles detected")
}
func TestRegistryAdd(t *testing.T) {
s1 := newTestService(t)
s2 := newTestService(t)
registry, err := NewRegistry(context.Background(), slog.New(slog.DiscardHandler), NewNamedService(MustNewName("s1"), s1))
require.NoError(t, err)
require.NoError(t, registry.Add(context.Background(), NewNamedService(MustNewName("s2"), s2)))
ctx := context.Background()
registry.Start(ctx)
require.NoError(t, registry.AwaitHealthy(ctx))
byState := registry.ServicesByState()
assert.Len(t, byState[StateRunning], 2)
assert.True(t, registry.IsHealthy())
assert.NoError(t, registry.Stop(ctx))
}
func TestRegistryAddDuplicateReturnsError(t *testing.T) {
s1 := newTestService(t)
registry, err := NewRegistry(context.Background(), slog.New(slog.DiscardHandler), NewNamedService(MustNewName("s1"), s1))
require.NoError(t, err)
err = registry.Add(context.Background(), NewNamedService(MustNewName("s1"), newTestService(t)))
assert.Error(t, err)
assert.Contains(t, err.Error(), "duplicate service name")
}
func TestRegistryAddWithDependency(t *testing.T) {
s1 := newHealthyTestService(t)
s2 := newTestService(t)
registry, err := NewRegistry(context.Background(), slog.New(slog.DiscardHandler), NewNamedService(MustNewName("s1"), s1))
require.NoError(t, err)
// s2 depends on the already registered s1.
require.NoError(t, registry.Add(context.Background(), NewNamedService(MustNewName("s2"), s2, MustNewName("s1"))))
ctx := context.Background()
registry.Start(ctx)
// s2 stays in STARTING until s1 is healthy.
require.Eventually(t, func() bool {
byState := registry.ServicesByState()
return len(byState[StateStarting]) == 2
}, time.Second, time.Millisecond)
close(s1.healthyC)
require.NoError(t, registry.AwaitHealthy(ctx))
assert.True(t, registry.IsHealthy())
assert.NoError(t, registry.Stop(ctx))
}

View File

@@ -3,15 +3,16 @@ package flagger
import "github.com/SigNoz/signoz/pkg/types/featuretypes"
var (
FeatureUseSpanMetrics = featuretypes.MustNewName("use_span_metrics")
FeatureKafkaSpanEval = featuretypes.MustNewName("kafka_span_eval")
FeatureHideRootUser = featuretypes.MustNewName("hide_root_user")
FeaturePutMetersInZeus = featuretypes.MustNewName("put_meters_in_zeus")
FeatureUseMeterReporter = featuretypes.MustNewName("use_meter_reporter")
FeatureUseJSONBody = featuretypes.MustNewName("use_json_body")
FeatureEnableAIObservability = featuretypes.MustNewName("enable_ai_observability")
FeatureEnableMetricsReduction = featuretypes.MustNewName("enable_metrics_reduction")
FeatureResolveSemconvFamilies = featuretypes.MustNewName("resolve_semconv_families")
FeatureUseSpanMetrics = featuretypes.MustNewName("use_span_metrics")
FeatureKafkaSpanEval = featuretypes.MustNewName("kafka_span_eval")
FeatureHideRootUser = featuretypes.MustNewName("hide_root_user")
FeaturePutMetersInZeus = featuretypes.MustNewName("put_meters_in_zeus")
FeatureUseMeterReporter = featuretypes.MustNewName("use_meter_reporter")
FeatureUseJSONBody = featuretypes.MustNewName("use_json_body")
FeatureEnableAIObservability = featuretypes.MustNewName("enable_ai_observability")
FeatureEnableMetricsReduction = featuretypes.MustNewName("enable_metrics_reduction")
FeatureUsePrometheusClickhouseV2 = featuretypes.MustNewName("use_prometheus_clickhouse_v2")
FeatureResolveSemconvFamilies = featuretypes.MustNewName("resolve_semconv_families")
)
func MustNewRegistry() featuretypes.Registry {
@@ -80,6 +81,14 @@ func MustNewRegistry() featuretypes.Registry {
DefaultVariant: featuretypes.MustNewName("disabled"),
Variants: featuretypes.NewBooleanVariants(),
},
&featuretypes.Feature{
Name: FeatureUsePrometheusClickhouseV2,
Kind: featuretypes.KindBoolean,
Stage: featuretypes.StageExperimental,
Description: "Runs PromQL queries on the clickhousev2 provider alongside the served engine result and logs any difference; serving is unaffected.",
DefaultVariant: featuretypes.MustNewName("disabled"),
Variants: featuretypes.NewBooleanVariants(),
},
&featuretypes.Feature{
Name: FeatureResolveSemconvFamilies,
Kind: featuretypes.KindBoolean,

View File

@@ -1,17 +0,0 @@
package middleware
import (
"net/http"
gorillahandlers "github.com/gorilla/handlers"
)
type Compress struct{}
func NewCompress() *Compress {
return &Compress{}
}
func (middleware *Compress) Wrap(next http.Handler) http.Handler {
return gorillahandlers.CompressHandler(next)
}

View File

@@ -1,25 +0,0 @@
package middleware
import (
"net/http"
"github.com/rs/cors"
)
type Cors struct {
cors *cors.Cors
}
func NewCors() *Cors {
return &Cors{
cors: cors.New(cors.Options{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "DELETE", "POST", "PUT", "PATCH", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "cache-control"},
}),
}
}
func (middleware *Cors) Wrap(next http.Handler) http.Handler {
return middleware.cors.Handler(next)
}

View File

@@ -1,43 +0,0 @@
package middleware
import (
"net/http"
"slices"
"github.com/gorilla/mux"
"go.opentelemetry.io/contrib/instrumentation/github.com/gorilla/mux/otelmux"
"go.opentelemetry.io/otel/metric"
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/trace"
)
// defaultExcludedRoutes are the health endpoints kept out of tracing/metrics to
// avoid drowning telemetry in probe traffic.
var defaultExcludedRoutes = []string{
"/api/v1/health",
"/api/v2/healthz",
"/api/v2/readyz",
"/api/v2/livez",
}
type Otel struct {
wrap mux.MiddlewareFunc
}
func NewOtel(service string, meterProvider metric.MeterProvider, tracerProvider trace.TracerProvider) *Otel {
return &Otel{
wrap: otelmux.Middleware(
service,
otelmux.WithMeterProvider(meterProvider),
otelmux.WithTracerProvider(tracerProvider),
otelmux.WithPropagators(propagation.NewCompositeTextMapPropagator(propagation.Baggage{}, propagation.TraceContext{})),
otelmux.WithFilter(func(r *http.Request) bool {
return !slices.Contains(defaultExcludedRoutes, r.URL.Path)
}),
),
}
}
func (middleware *Otel) Wrap(next http.Handler) http.Handler {
return middleware.wrap(next)
}

View File

@@ -1,18 +1,9 @@
package server
import "time"
// Config holds the configuration for http.
type Config struct {
//Address specifies the TCP address for the server to listen on, in the form "host:port".
// If empty, ":http" (port 80) is used. The service names are defined in RFC 6335 and assigned by IANA.
// See net.Dial for details of the address format.
Address string `mapstructure:"address"`
// ReadTimeout bounds reading an entire request, including the body. Zero means no timeout.
ReadTimeout time.Duration `mapstructure:"read_timeout"`
// WriteTimeout bounds writing the response. Zero means no timeout, required for
// streaming endpoints that hold the connection open.
WriteTimeout time.Duration `mapstructure:"write_timeout"`
}

View File

@@ -31,8 +31,8 @@ func New(logger *slog.Logger, cfg Config, handler http.Handler) (*Server, error)
srv := &http.Server{
Addr: cfg.Address,
Handler: handler,
ReadTimeout: cfg.ReadTimeout,
WriteTimeout: cfg.WriteTimeout,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
MaxHeaderBytes: 1 << 20,
}

View File

@@ -6,7 +6,6 @@ import (
"net/http"
nethttppprof "net/http/pprof"
runtimepprof "runtime/pprof"
"time"
"github.com/SigNoz/signoz/pkg/factory"
httpserver "github.com/SigNoz/signoz/pkg/http/server"
@@ -24,7 +23,7 @@ func NewFactory() factory.ProviderFactory[pprof.PProf, pprof.Config] {
func New(_ context.Context, settings factory.ProviderSettings, config pprof.Config) (pprof.PProf, error) {
server, err := httpserver.New(
settings.Logger.With(slog.String("pkg", "github.com/SigNoz/signoz/pkg/pprof/httppprof")),
httpserver.Config{Address: config.Address, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second},
httpserver.Config{Address: config.Address},
newHandler(),
)
if err != nil {

View File

@@ -0,0 +1,90 @@
package clickhouseprometheus
import (
"context"
"sync"
"github.com/SigNoz/signoz/pkg/prometheus"
"github.com/prometheus/prometheus/prompb"
"github.com/prometheus/prometheus/storage"
)
// statementRecorder collects the statements a PromQL evaluation would run.
// Safe for concurrent use: the engine may Select selectors concurrently.
type statementRecorder struct {
mu sync.Mutex
statements []prometheus.CapturedStatement
}
func (r *statementRecorder) record(query string, args []any) {
r.mu.Lock()
defer r.mu.Unlock()
r.statements = append(r.statements, prometheus.CapturedStatement{Query: query, Args: args})
}
func (r *statementRecorder) Statements() []prometheus.CapturedStatement {
r.mu.Lock()
defer r.mu.Unlock()
out := make([]prometheus.CapturedStatement, len(r.statements))
copy(out, r.statements)
return out
}
// captureClient builds the same SQL as the real client but records it and
// returns an empty result instead of executing.
type captureClient struct {
*client
recorder *statementRecorder
}
func (c *captureClient) Read(ctx context.Context, query *prompb.Query, _ bool) (storage.SeriesSet, error) {
// Raw-SQL passthrough ({job="rawsql", query="..."}): record the raw query.
if len(query.Matchers) == 2 {
var hasJob bool
var queryString string
for _, m := range query.Matchers {
if m.Type == prompb.LabelMatcher_EQ && m.Name == "job" && m.Value == "rawsql" {
hasJob = true
}
if m.Type == prompb.LabelMatcher_EQ && m.Name == "query" {
queryString = m.Value
}
}
if hasJob && queryString != "" {
c.recorder.record(queryString, nil)
return storage.EmptySeriesSet(), nil
}
}
// Without executing the series lookup, only an exact-name selector's
// metric name is known.
var metricNames []string
for _, matcher := range query.Matchers {
if matcher.Name == "__name__" && matcher.Type == prompb.LabelMatcher_EQ {
metricNames = []string{matcher.Value}
}
}
// Build the executing path's queries, but only record them.
sub, err := seriesLookupQuery(query, true)
if err != nil {
return nil, err
}
samplesQuery, samplesArgs := buildSamplesQuery(int64(query.StartTimestampMs), int64(query.EndTimestampMs), metricNames, sub)
c.recorder.record(samplesQuery, samplesArgs)
return storage.EmptySeriesSet(), nil
}
// captureQueryable adapts the capturing read client to storage.Queryable.
type captureQueryable struct {
inner storage.SampleAndChunkQueryable
}
func (c captureQueryable) Querier(mint, maxt int64) (storage.Querier, error) {
querier, err := c.inner.Querier(mint, maxt)
if err != nil {
return nil, err
}
return storage.NewMergeQuerier(nil, []storage.Querier{querier}, storage.ChainedSeriesMerge), nil
}

View File

@@ -0,0 +1,517 @@
package clickhouseprometheus
import (
"context"
"fmt"
"math"
"sort"
"sync"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
"github.com/SigNoz/signoz/pkg/types/instrumentationtypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/cespare/xxhash/v2"
"github.com/huandu/go-sqlbuilder"
promValue "github.com/prometheus/prometheus/model/value"
"github.com/prometheus/prometheus/prompb"
"github.com/prometheus/prometheus/storage"
"github.com/prometheus/prometheus/storage/remote"
)
type client struct {
settings factory.ScopedProviderSettings
telemetryStore telemetrystore.TelemetryStore
}
func NewReadClient(settings factory.ScopedProviderSettings, telemetryStore telemetrystore.TelemetryStore) remote.ReadClient {
return &client{
settings: settings,
telemetryStore: telemetryStore,
}
}
func (client *client) Read(ctx context.Context, query *prompb.Query, sortSeries bool) (storage.SeriesSet, error) {
if len(query.Matchers) == 2 {
var hasJob bool
var queryString string
for _, m := range query.Matchers {
if m.Type == prompb.LabelMatcher_EQ && m.Name == "job" && m.Value == "rawsql" {
hasJob = true
}
if m.Type == prompb.LabelMatcher_EQ && m.Name == "query" {
queryString = m.Value
}
}
if hasJob && queryString != "" {
res, err := client.queryRaw(ctx, queryString, int64(query.EndTimestampMs))
if err != nil {
return nil, err
}
return remote.FromQueryResult(sortSeries, res), nil
}
}
lookup, err := seriesLookupQuery(query, false)
if err != nil {
return nil, err
}
lookupSQL, lookupArgs := lookup.BuildWithFlavor(sqlbuilder.ClickHouse)
fingerprints, metricNames, err := client.getFingerprintsFromClickhouseQuery(ctx, lookupSQL, lookupArgs)
if err != nil {
return nil, err
}
if len(fingerprints) == 0 {
return remote.FromQueryResult(sortSeries, new(prompb.QueryResult)), nil
}
sub, err := seriesLookupQuery(query, true)
if err != nil {
return nil, err
}
samplesSQL, samplesArgs := buildSamplesQuery(int64(query.StartTimestampMs), int64(query.EndTimestampMs), metricNames, sub)
res := new(prompb.QueryResult)
timeseries, err := client.querySamples(ctx, samplesSQL, samplesArgs, fingerprints)
if err != nil {
return nil, err
}
res.Timeseries = timeseries
return remote.FromQueryResult(sortSeries, res), nil
}
func (c *client) ReadMultiple(ctx context.Context, queries []*prompb.Query, sortSeries bool) (storage.SeriesSet, error) {
if len(queries) == 0 {
return storage.EmptySeriesSet(), nil
}
if len(queries) == 1 {
return c.Read(ctx, queries[0], sortSeries)
}
type result struct {
ss storage.SeriesSet
err error
}
results := make([]result, len(queries))
var wg sync.WaitGroup
wg.Add(len(queries))
for i, q := range queries {
go func(i int, q *prompb.Query) {
defer wg.Done()
ss, err := c.Read(ctx, q, sortSeries)
results[i] = result{ss, err}
}(i, q)
}
wg.Wait()
sets := make([]storage.SeriesSet, 0, len(queries))
for _, r := range results {
if r.err != nil {
return nil, r.err
}
sets = append(sets, r.ss)
}
return storage.NewMergeSeriesSet(sets, 0, storage.ChainedSeriesMerge), nil
}
// anchorRegex makes a pattern fully anchored, the way Prometheus compiles
// matcher regexes; ClickHouse's match() would otherwise substring-match.
func anchorRegex(pattern string) string {
return "^(?:" + pattern + ")$"
}
// seriesLookupQuery builds the time-series lookup. It returns a builder so
// the samples query can embed it as a subquery with the args merged in
// render order by the builder instead of hand-numbered placeholders.
func seriesLookupQuery(query *prompb.Query, subQuery bool) (*sqlbuilder.SelectBuilder, error) {
sb := sqlbuilder.NewSelectBuilder()
if subQuery {
sb.Select("fingerprint")
} else {
sb.Select("fingerprint", "any(labels)")
}
start, end, tableName := getStartAndEndAndTableName(query.StartTimestampMs, query.EndTimestampMs)
sb.From(databaseName + "." + tableName)
sb.Where("temporality IN ['Cumulative', 'Unspecified']")
// Inclusive upper bound: registration rows are hour-floored by the
// exporter, so a series first registered in the hour starting exactly at
// `end` would otherwise be invisible while its samples (<= end) are in
// range.
sb.Where(fmt.Sprintf("unix_milli >= %d AND unix_milli <= %d", start, end))
for _, m := range query.Matchers {
if m.Name == "__name__" {
// __name__ maps onto the metric_name column per matcher type;
// reducing regex/negated/absent name matchers to one equality
// made such selectors silently return empty.
switch m.Type {
case prompb.LabelMatcher_EQ:
sb.Where(sb.E("metric_name", m.Value))
case prompb.LabelMatcher_NEQ:
sb.Where(sb.NE("metric_name", m.Value))
case prompb.LabelMatcher_RE:
sb.Where(fmt.Sprintf("match(metric_name, %s)", sb.Var(anchorRegex(m.Value))))
case prompb.LabelMatcher_NRE:
sb.Where(fmt.Sprintf("not match(metric_name, %s)", sb.Var(anchorRegex(m.Value))))
default:
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported or invalid matcher type: %s", m.Type.String())
}
continue
}
switch m.Type {
case prompb.LabelMatcher_EQ:
sb.Where(fmt.Sprintf("JSONExtractString(labels, %s) = %s", sb.Var(m.Name), sb.Var(m.Value)))
case prompb.LabelMatcher_NEQ:
sb.Where(fmt.Sprintf("JSONExtractString(labels, %s) != %s", sb.Var(m.Name), sb.Var(m.Value)))
case prompb.LabelMatcher_RE:
sb.Where(fmt.Sprintf("match(JSONExtractString(labels, %s), %s)", sb.Var(m.Name), sb.Var(anchorRegex(m.Value))))
case prompb.LabelMatcher_NRE:
sb.Where(fmt.Sprintf("not match(JSONExtractString(labels, %s), %s)", sb.Var(m.Name), sb.Var(anchorRegex(m.Value))))
default:
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported or invalid matcher type: %s", m.Type.String())
}
}
sb.GroupBy("fingerprint")
return sb, nil
}
func (client *client) getFingerprintsFromClickhouseQuery(ctx context.Context, query string, args []any) (map[uint64][]prompb.Label, []string, error) {
ctx = client.withClickhousePrometheusContext(ctx, "getFingerprintsFromClickhouseQuery")
rows, err := client.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
if err != nil {
return nil, nil, err
}
defer rows.Close()
fingerprints := make(map[uint64][]prompb.Label)
nameSet := make(map[string]struct{})
var fingerprint uint64
var labelString string
for rows.Next() {
if err = rows.Scan(&fingerprint, &labelString); err != nil {
return nil, nil, err
}
labels, metricName, err := unmarshalLabels(labelString)
if err != nil {
return nil, nil, err
}
fingerprints[fingerprint] = labels
if metricName != "" {
nameSet[metricName] = struct{}{}
}
}
if err := rows.Err(); err != nil {
return nil, nil, err
}
metricNames := make([]string, 0, len(nameSet))
for name := range nameSet {
metricNames = append(metricNames, name)
}
sort.Strings(metricNames)
return fingerprints, metricNames, nil
}
// buildSamplesQuery renders the samples SQL for the series selected by
// subQuery. The metric_name condition exists only for primary-key pruning;
// the fingerprint filter already selects the right rows.
//
// Time bounds are inclusive on both ends because that is Prometheus's
// storage contract: Select(mint, maxt) returns [start, end] and the engine
// itself trims each evaluation window to left-open (T-window, T], so the
// sample at exactly `end` belongs to the last point. This deliberately
// differs from the query builder's `unix_milli < end`, which is correct for
// its own model — toStartOfInterval buckets covering [t, t+step), where a
// sample at `end` falls in an unrendered bucket and end-exclusive ranges
// tile exactly across cached time slices.
func buildSamplesQuery(start int64, end int64, metricNames []string, sub *sqlbuilder.SelectBuilder) (string, []any) {
sb := sqlbuilder.NewSelectBuilder()
sb.Select("metric_name", "fingerprint", "unix_milli", "value", "flags")
sb.From(databaseName + "." + distributedSamplesV4)
if len(metricNames) > 0 {
names := make([]any, len(metricNames))
for i, name := range metricNames {
names[i] = name
}
sb.Where(sb.In("metric_name", names...))
}
sb.Where(fmt.Sprintf("fingerprint GLOBAL IN (%s)", sb.Var(sub)))
sb.Where(sb.GTE("unix_milli", start), sb.LTE("unix_milli", end))
sb.OrderBy("fingerprint", "unix_milli")
return sb.BuildWithFlavor(sqlbuilder.ClickHouse)
}
func (client *client) querySamples(ctx context.Context, query string, args []any, fingerprints map[uint64][]prompb.Label) ([]*prompb.TimeSeries, error) {
ctx = client.withClickhousePrometheusContext(ctx, "querySamples")
rows, err := client.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var res []*prompb.TimeSeries
var ts *prompb.TimeSeries
var metricName string
var fingerprint, prevFingerprint uint64
var timestampMs, prevTimestamp int64
var value float64
var flags uint32
prevTimestamp = math.MinInt64
for rows.Next() {
if err := rows.Scan(&metricName, &fingerprint, &timestampMs, &value, &flags); err != nil {
return nil, err
}
// collect samples in time series
if fingerprint != prevFingerprint {
// add collected time series to result
prevFingerprint = fingerprint
if ts != nil {
res = append(res, ts)
}
labels := fingerprints[fingerprint]
ts = &prompb.TimeSeries{
Labels: labels,
}
prevTimestamp = math.MinInt64
}
if flags&1 == 1 {
value = math.Float64frombits(promValue.StaleNaN)
}
if timestampMs == prevTimestamp {
continue
}
prevTimestamp = timestampMs
// add samples to current time series
ts.Samples = append(ts.Samples, prompb.Sample{
Timestamp: timestampMs,
Value: value,
})
}
// add last time series
if ts != nil {
res = append(res, ts)
}
if err := rows.Err(); err != nil {
return nil, err
}
return mergeSeriesWithIdenticalLabels(res), nil
}
// mergeSeriesWithIdenticalLabels collapses series sharing one labelset into
// one series each. Distinct fingerprints can map to one labelset: a label
// value goes empty over a series' lifetime (#8563), the fingerprint
// algorithm changes across exporter versions, or the env changes. The
// engine treats the labelset as series identity — duplicates raise
// "duplicate series", and #8563's workaround of injecting a synthetic
// fingerprint label silently broke without() and vector matching. Merging
// at the last point before hand-off keeps any future input-side label
// normalization collision-safe. Grouping is by an order-insensitive 64-bit
// hash so the common no-collision case costs one hash and one map insert
// per series; hash-equal groups are confirmed by exact labelset equality
// before any merge. Regression:
// TestClient_QuerySamplesMergesIdenticalLabelSets and
// tests/integration/tests/promqlconformance/02_fingerprint_probe.py.
func mergeSeriesWithIdenticalLabels(series []*prompb.TimeSeries) []*prompb.TimeSeries {
if len(series) < 2 {
return series
}
groups := make(map[uint64][]*prompb.TimeSeries, len(series))
order := make([]uint64, 0, len(series))
for _, ts := range series {
key := labelsHash(ts.Labels)
if _, ok := groups[key]; !ok {
order = append(order, key)
}
groups[key] = append(groups[key], ts)
}
if len(order) == len(series) {
return series
}
res := make([]*prompb.TimeSeries, 0, len(order))
for _, key := range order {
group := groups[key]
if len(group) == 1 {
res = append(res, group[0])
continue
}
for _, sub := range splitByLabelSet(group) {
if len(sub) == 1 {
res = append(res, sub[0])
continue
}
res = append(res, mergeSamples(sub))
}
}
return res
}
var labelHashSep = []byte{0xff}
// labelsHash combines per-label hashes commutatively, so the stored JSON's
// key order (not canonical across fingerprints) needs no sort.
func labelsHash(lbls []prompb.Label) uint64 {
var h uint64
var d xxhash.Digest
for _, l := range lbls {
d.Reset()
_, _ = d.WriteString(l.Name)
_, _ = d.Write(labelHashSep)
_, _ = d.WriteString(l.Value)
h += d.Sum64()
}
return h
}
// splitByLabelSet partitions a hash-equal group into sub-groups of exactly
// equal labelsets, preserving input order; series that merely collide on the
// 64-bit hash must not be merged.
func splitByLabelSet(group []*prompb.TimeSeries) [][]*prompb.TimeSeries {
var out [][]*prompb.TimeSeries
outer:
for _, ts := range group {
for i, sub := range out {
if labelSetsEqual(sub[0].Labels, ts.Labels) {
out[i] = append(out[i], ts)
continue outer
}
}
out = append(out, []*prompb.TimeSeries{ts})
}
return out
}
func labelSetsEqual(a, b []prompb.Label) bool {
if len(a) != len(b) {
return false
}
for _, la := range a {
found := false
for _, lb := range b {
if la.Name == lb.Name {
found = la.Value == lb.Value
break
}
}
if !found {
return false
}
}
return true
}
// mergeSamples k-way merges sample streams that share one labelset. On
// equal timestamps the highest fingerprint wins: the input is in ascending
// fingerprint order (samples SQL), keeping the choice deterministic.
func mergeSamples(group []*prompb.TimeSeries) *prompb.TimeSeries {
merged := &prompb.TimeSeries{Labels: group[0].Labels}
idx := make([]int, len(group))
for {
minTs := int64(math.MaxInt64)
for i, ts := range group {
if idx[i] < len(ts.Samples) && ts.Samples[idx[i]].Timestamp < minTs {
minTs = ts.Samples[idx[i]].Timestamp
}
}
if minTs == math.MaxInt64 {
return merged
}
var chosen prompb.Sample
for i, ts := range group {
if idx[i] < len(ts.Samples) && ts.Samples[idx[i]].Timestamp == minTs {
chosen = ts.Samples[idx[i]]
idx[i]++
}
}
merged.Samples = append(merged.Samples, chosen)
}
}
func (client *client) queryRaw(ctx context.Context, query string, ts int64) (*prompb.QueryResult, error) {
ctx = client.withClickhousePrometheusContext(ctx, "queryRaw")
rows, err := client.telemetryStore.ClickhouseDB().Query(ctx, query)
if err != nil {
return nil, err
}
defer rows.Close()
columns := rows.Columns()
var res prompb.QueryResult
targets := make([]any, len(columns))
for i := range targets {
targets[i] = new(scanner)
}
for rows.Next() {
if err = rows.Scan(targets...); err != nil {
return nil, err
}
labels := make([]prompb.Label, 0, len(columns))
var value float64
for i, c := range columns {
v := targets[i].(*scanner)
switch c {
case "value":
value = v.f
default:
labels = append(labels, prompb.Label{
Name: c,
Value: v.s,
})
}
}
res.Timeseries = append(res.Timeseries, &prompb.TimeSeries{
Labels: labels,
Samples: []prompb.Sample{{
Value: value,
Timestamp: ts,
}},
})
}
if err = rows.Err(); err != nil {
return nil, err
}
return &res, nil
}
func (client *client) withClickhousePrometheusContext(ctx context.Context, functionName string) context.Context {
comments := map[string]string{
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalMetrics.StringValue(),
instrumentationtypes.CodeNamespace: "clickhouse-prometheus",
instrumentationtypes.CodeFunctionName: functionName,
}
return ctxtypes.NewContextWithCommentVals(ctx, comments)
}

View File

@@ -0,0 +1,382 @@
package clickhouseprometheus
import (
"context"
"sort"
"testing"
cmock "github.com/SigNoz/clickhouse-go-mock"
"github.com/SigNoz/signoz/pkg/telemetrystore/telemetrystoretest"
"github.com/stretchr/testify/require"
"github.com/DATA-DOG/go-sqlmock"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/huandu/go-sqlbuilder"
"github.com/prometheus/prometheus/prompb"
"github.com/stretchr/testify/assert"
)
// Test for querySamples method.
func TestClient_QuerySamples(t *testing.T) {
ctx := context.Background()
cols := make([]cmock.ColumnType, 0)
cols = append(cols, cmock.ColumnType{Name: "metric_name", Type: "String"})
cols = append(cols, cmock.ColumnType{Name: "fingerprint", Type: "UInt64"})
cols = append(cols, cmock.ColumnType{Name: "unix_milli", Type: "Int64"})
cols = append(cols, cmock.ColumnType{Name: "value", Type: "Float64"})
cols = append(cols, cmock.ColumnType{Name: "flags", Type: "UInt32"})
tests := []struct {
name string
start int64
end int64
fingerprints map[uint64][]prompb.Label
metricNames []string
subQuery string
args []any
setupMock func(mock cmock.ClickConnMockCommon, args ...any)
expectedTimeSeries int
expectError bool
description string
result []*prompb.TimeSeries
}{
{
name: "successful samples retrieval",
start: int64(1000),
end: int64(2000),
fingerprints: map[uint64][]prompb.Label{
123: {
{Name: "__name__", Value: "cpu_usage"},
{Name: "instance", Value: "localhost:9090"},
},
456: {
{Name: "__name__", Value: "cpu_usage"},
{Name: "instance", Value: "localhost:9091"},
},
},
metricNames: []string{"cpu_usage"},
subQuery: "SELECT metric_name, fingerprint, unix_milli, value, flags",
expectedTimeSeries: 2,
expectError: false,
description: "Should successfully retrieve samples for multiple time series",
setupMock: func(mock cmock.ClickConnMockCommon, args ...any) {
values := [][]interface{}{
{"cpu_usage", uint64(123), int64(1001), float64(1.1), uint32(0)},
{"cpu_usage", uint64(123), int64(1001), float64(1.1), uint32(0)},
{"cpu_usage", uint64(456), int64(1001), float64(1.2), uint32(0)},
{"cpu_usage", uint64(456), int64(1001), float64(1.2), uint32(0)},
{"cpu_usage", uint64(456), int64(1001), float64(1.2), uint32(0)},
}
mock.ExpectQuery("SELECT metric_name, fingerprint, unix_milli, value, flags").WithArgs(args...).WillReturnRows(
cmock.NewRows(cols, values),
)
},
result: []*prompb.TimeSeries{
{
Labels: []prompb.Label{
{Name: "__name__", Value: "cpu_usage"},
{Name: "instance", Value: "localhost:9090"},
},
Samples: []prompb.Sample{
{Timestamp: 1001, Value: 1.1},
},
},
{
Labels: []prompb.Label{
{Name: "__name__", Value: "cpu_usage"},
{Name: "instance", Value: "localhost:9091"},
},
Samples: []prompb.Sample{
{Timestamp: 1001, Value: 1.2},
},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
telemetryStore := telemetrystoretest.New(telemetrystore.Config{Provider: "clickhouse"}, sqlmock.QueryMatcherRegexp)
readClient := client{telemetryStore: telemetryStore}
if tt.setupMock != nil {
tt.setupMock(telemetryStore.Mock(), "cpu_usage", tt.start, tt.end)
}
result, err := readClient.querySamples(ctx, tt.subQuery, []any{"cpu_usage", tt.start, tt.end}, tt.fingerprints)
if tt.expectError {
assert.Error(t, err)
assert.Nil(t, result)
} else {
assert.NoError(t, err)
assert.Equal(t, tt.expectedTimeSeries, len(result))
assert.Equal(t, result, tt.result)
}
})
}
}
// Regression for the duplicate-series class behind #8563: fingerprints
// sharing one labelset must come back as one merged series, the higher
// fingerprint winning equal timestamps.
func TestClient_QuerySamplesMergesIdenticalLabelSets(t *testing.T) {
ctx := context.Background()
cols := []cmock.ColumnType{
{Name: "metric_name", Type: "String"},
{Name: "fingerprint", Type: "UInt64"},
{Name: "unix_milli", Type: "Int64"},
{Name: "value", Type: "Float64"},
{Name: "flags", Type: "UInt32"},
}
canary := []prompb.Label{
{Name: "__name__", Value: "requests"},
{Name: "group", Value: "canary"},
}
production := []prompb.Label{
{Name: "__name__", Value: "requests"},
{Name: "group", Value: "production"},
}
fingerprints := map[uint64][]prompb.Label{
100: canary,
200: canary,
300: production,
}
telemetryStore := telemetrystoretest.New(telemetrystore.Config{Provider: "clickhouse"}, sqlmock.QueryMatcherRegexp)
// Rows arrive ordered by (fingerprint, unix_milli), matching the SQL.
values := [][]any{
{"requests", uint64(100), int64(1000), float64(1.0), uint32(0)},
{"requests", uint64(100), int64(2000), float64(2.0), uint32(0)},
{"requests", uint64(200), int64(2000), float64(20.0), uint32(0)},
{"requests", uint64(200), int64(3000), float64(30.0), uint32(0)},
{"requests", uint64(300), int64(1500), float64(5.0), uint32(0)},
}
telemetryStore.Mock().ExpectQuery("SELECT metric_name, fingerprint, unix_milli, value, flags").
WithArgs("requests", int64(1000), int64(3000)).
WillReturnRows(cmock.NewRows(cols, values))
readClient := client{telemetryStore: telemetryStore}
result, err := readClient.querySamples(ctx, "SELECT metric_name, fingerprint, unix_milli, value, flags", []any{"requests", int64(1000), int64(3000)}, fingerprints)
require.NoError(t, err)
assert.Equal(t, []*prompb.TimeSeries{
{
Labels: canary,
Samples: []prompb.Sample{
{Timestamp: 1000, Value: 1.0},
{Timestamp: 2000, Value: 20.0},
{Timestamp: 3000, Value: 30.0},
},
},
{
Labels: production,
Samples: []prompb.Sample{
{Timestamp: 1500, Value: 5.0},
},
},
}, result)
}
func TestClient_getFingerprintsFromClickhouseQuery(t *testing.T) {
cols := []cmock.ColumnType{
{Name: "fingerprint", Type: "UInt64"},
{Name: "labels", Type: "String"},
}
sortLabels := func(ls []prompb.Label) {
sort.Slice(ls, func(i, j int) bool {
if ls[i].Name == ls[j].Name {
return ls[i].Value < ls[j].Value
}
return ls[i].Name < ls[j].Name
})
}
tests := []struct {
name string
start, end int64
metricName string
subQuery string
args []any
setupMock func(m cmock.ClickConnMockCommon, args ...any)
want map[uint64][]prompb.Label
wantNames []string
wantErr bool
}{
{
name: "happy-path - two fingerprints",
start: 1000,
end: 2000,
metricName: "cpu_usage",
subQuery: `SELECT fingerprint,labels`,
// args slice is empty here, but testcase still owns it
args: []any{},
setupMock: func(m cmock.ClickConnMockCommon, args ...any) {
rows := [][]any{
{uint64(123), `{"__name__":"cpu_usage","t1":"s1","t2":"s2"}`},
{uint64(234), `{"__name__":"cpu_usage","t1":"s1","t2":"s2","empty":""}`},
}
m.ExpectQuery(`SELECT fingerprint,labels`).WithArgs(args...).WillReturnRows(
cmock.NewRows(cols, rows),
)
},
// No synthetic fingerprint label (#8563), empty-valued labels
// dropped: both fingerprints present one labelset for
// querySamples to merge.
want: map[uint64][]prompb.Label{
123: {
{Name: "__name__", Value: "cpu_usage"},
{Name: "t1", Value: "s1"},
{Name: "t2", Value: "s2"},
},
234: {
{Name: "__name__", Value: "cpu_usage"},
{Name: "t1", Value: "s1"},
{Name: "t2", Value: "s2"},
},
},
wantNames: []string{"cpu_usage"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
ctx := context.Background()
store := telemetrystoretest.New(
telemetrystore.Config{Provider: "clickhouse"},
sqlmock.QueryMatcherRegexp,
)
if tc.setupMock != nil {
tc.setupMock(store.Mock(), tc.args...)
}
c := client{telemetryStore: store}
got, gotNames, err := c.getFingerprintsFromClickhouseQuery(ctx, tc.subQuery, tc.args)
if tc.wantErr {
require.Error(t, err)
require.Nil(t, got)
return
}
require.NoError(t, err)
assert.Equal(t, tc.wantNames, gotNames, "discovered metric names mismatch")
require.Equal(t, len(tc.want), len(got), "fingerprint map length mismatch")
for fp, expLabels := range tc.want {
gotLabels, ok := got[fp]
require.Truef(t, ok, "missing fingerprint %d", fp)
sortLabels(expLabels)
sortLabels(gotLabels)
assert.Equalf(t, expLabels, gotLabels, "labels mismatch for fingerprint %d", fp)
}
})
}
}
// Regression for nameless/regex-name selectors silently returning empty:
// the old code reduced every __name__ matcher to `metric_name = <value>`
// (empty string when absent). Regexes must come out anchored — Prometheus
// matcher semantics, while ClickHouse match() substring-matches.
func TestQueryToClickhouseQueryNameMatchers(t *testing.T) {
query := func(matchers ...*prompb.LabelMatcher) *prompb.Query {
return &prompb.Query{StartTimestampMs: 0, EndTimestampMs: 1000, Matchers: matchers}
}
tests := []struct {
name string
query *prompb.Query
contains []string
absent []string
args []any
}{
{
name: "exact name",
query: query(&prompb.LabelMatcher{Type: prompb.LabelMatcher_EQ, Name: "__name__", Value: "cpu_usage"}),
contains: []string{"metric_name = ?"},
args: []any{"cpu_usage"},
},
{
name: "regex name is anchored",
query: query(&prompb.LabelMatcher{Type: prompb.LabelMatcher_RE, Name: "__name__", Value: ".+"}),
contains: []string{"match(metric_name, ?)"},
args: []any{"^(?:.+)$"},
},
{
name: "nameless selector has no metric_name condition",
query: query(
&prompb.LabelMatcher{Type: prompb.LabelMatcher_EQ, Name: "job", Value: "api"},
&prompb.LabelMatcher{Type: prompb.LabelMatcher_NRE, Name: "group", Value: "can.*"},
),
contains: []string{
"JSONExtractString(labels, ?) = ?",
"not match(JSONExtractString(labels, ?), ?)",
},
absent: []string{"metric_name"},
args: []any{"job", "api", "group", "^(?:can.*)$"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
lookup, err := seriesLookupQuery(tt.query, false)
require.NoError(t, err)
sql, args := lookup.BuildWithFlavor(sqlbuilder.ClickHouse)
for _, want := range tt.contains {
assert.Contains(t, sql, want)
}
for _, notWant := range tt.absent {
assert.NotContains(t, sql, notWant)
}
assert.Equal(t, tt.args, args)
})
}
}
// The samples query narrows by the metric names the lookup discovered and
// embeds the series lookup as a subquery, the builder merging its args in
// render order.
func TestBuildSamplesQueryMetricNames(t *testing.T) {
sub := sqlbuilder.NewSelectBuilder()
sub.Select("fingerprint")
sub.From("t")
sub.Where(sub.E("k", "v"))
sql, args := buildSamplesQuery(5, 9, []string{"a_total", "b_total"}, sub)
assert.Contains(t, sql, "metric_name IN (?, ?)")
assert.Contains(t, sql, "fingerprint GLOBAL IN (SELECT fingerprint FROM t WHERE k = ?)")
assert.Contains(t, sql, "unix_milli >= ? AND unix_milli <= ?")
assert.Equal(t, []any{"a_total", "b_total", "v", int64(5), int64(9)}, args)
sub2 := sqlbuilder.NewSelectBuilder()
sub2.Select("fingerprint")
sub2.From("t")
sql, args = buildSamplesQuery(5, 9, nil, sub2)
assert.NotContains(t, sql, "metric_name IN")
assert.Equal(t, []any{int64(5), int64(9)}, args)
}
// Hash grouping must stay order-insensitive (stored JSON key order is not
// canonical across fingerprints), and a 64-bit hash collision between
// distinct labelsets must not merge them — splitByLabelSet is that guard.
func TestLabelsHashAndCollisionSplit(t *testing.T) {
lbls := []prompb.Label{
{Name: "__name__", Value: "requests"},
{Name: "job", Value: "api"},
{Name: "instance", Value: "0"},
}
reversed := []prompb.Label{lbls[2], lbls[1], lbls[0]}
assert.Equal(t, labelsHash(lbls), labelsHash(reversed))
a := &prompb.TimeSeries{Labels: []prompb.Label{{Name: "job", Value: "x"}}}
b := &prompb.TimeSeries{Labels: []prompb.Label{{Name: "job", Value: "y"}}}
c := &prompb.TimeSeries{Labels: []prompb.Label{{Name: "job", Value: "x"}}}
got := splitByLabelSet([]*prompb.TimeSeries{a, b, c})
require.Len(t, got, 2)
assert.Equal(t, []*prompb.TimeSeries{a, c}, got[0])
assert.Equal(t, []*prompb.TimeSeries{b}, got[1])
}

View File

@@ -0,0 +1,34 @@
package clickhouseprometheus
import (
"encoding/json"
"github.com/prometheus/prometheus/prompb"
)
// Unmarshals JSON into Prometheus labels. It does not preserve order.
// Empty-valued labels are dropped: Prometheus treats them as absent, and
// keeping them lets two fingerprints present duplicate labelsets to the
// engine (the incident behind #8563).
func unmarshalLabels(s string) ([]prompb.Label, string, error) {
var metricName string
m := make(map[string]string)
if err := json.Unmarshal([]byte(s), &m); err != nil {
return nil, metricName, err
}
res := make([]prompb.Label, 0, len(m))
for n, v := range m {
if v == "" {
continue
}
if n == "__name__" {
metricName = v
}
res = append(res, prompb.Label{
Name: n,
Value: v,
})
}
return res, metricName, nil
}

View File

@@ -0,0 +1,82 @@
package clickhouseprometheus
import (
"testing"
"github.com/prometheus/prometheus/prompb"
"github.com/stretchr/testify/assert"
)
func mkSeries(value string, samples ...prompb.Sample) *prompb.TimeSeries {
return &prompb.TimeSeries{
Labels: []prompb.Label{{Name: "job", Value: value}},
Samples: samples,
}
}
func TestMergeSeriesWithIdenticalLabels(t *testing.T) {
s := func(ts int64, v float64) prompb.Sample { return prompb.Sample{Timestamp: ts, Value: v} }
t.Run("empty and single series pass through untouched", func(t *testing.T) {
assert.Nil(t, mergeSeriesWithIdenticalLabels(nil))
one := []*prompb.TimeSeries{mkSeries("a", s(1, 1))}
got := mergeSeriesWithIdenticalLabels(one)
assert.Equal(t, one, got)
})
t.Run("no collisions returns the input slice as is", func(t *testing.T) {
in := []*prompb.TimeSeries{mkSeries("a", s(1, 1)), mkSeries("b", s(1, 2))}
got := mergeSeriesWithIdenticalLabels(in)
// same backing slice: the fast path must not rebuild anything
assert.Equal(t, &in[0], &got[0])
})
t.Run("disjoint streams concatenate in timestamp order", func(t *testing.T) {
// the #8563 shape: the series transitioned fingerprints at a point
// in time, so the streams do not overlap at all
got := mergeSeriesWithIdenticalLabels([]*prompb.TimeSeries{
mkSeries("a", s(1, 1), s(2, 2)),
mkSeries("a", s(3, 3), s(4, 4)),
})
assert.Equal(t, []prompb.Sample{s(1, 1), s(2, 2), s(3, 3), s(4, 4)}, got[0].Samples)
})
t.Run("three fingerprints one labelset", func(t *testing.T) {
got := mergeSeriesWithIdenticalLabels([]*prompb.TimeSeries{
mkSeries("a", s(1, 1), s(4, 4)),
mkSeries("a", s(2, 2)),
mkSeries("a", s(3, 3)),
})
assert.Len(t, got, 1)
assert.Equal(t, []prompb.Sample{s(1, 1), s(2, 2), s(3, 3), s(4, 4)}, got[0].Samples)
})
t.Run("equal timestamps everywhere keep the last stream's value", func(t *testing.T) {
// input is in ascending fingerprint order; the highest wins each tie
got := mergeSeriesWithIdenticalLabels([]*prompb.TimeSeries{
mkSeries("a", s(1, 1), s(2, 1)),
mkSeries("a", s(1, 9), s(2, 9)),
})
assert.Equal(t, []prompb.Sample{s(1, 9), s(2, 9)}, got[0].Samples)
})
t.Run("zero-sample series in a group is harmless", func(t *testing.T) {
got := mergeSeriesWithIdenticalLabels([]*prompb.TimeSeries{
mkSeries("a"),
mkSeries("a", s(1, 1)),
})
assert.Len(t, got, 1)
assert.Equal(t, []prompb.Sample{s(1, 1)}, got[0].Samples)
})
t.Run("colliding and distinct series interleave without cross-talk", func(t *testing.T) {
got := mergeSeriesWithIdenticalLabels([]*prompb.TimeSeries{
mkSeries("a", s(1, 1)),
mkSeries("b", s(1, 2)),
mkSeries("a", s(2, 3)),
})
assert.Len(t, got, 2)
assert.Equal(t, []prompb.Sample{s(1, 1), s(2, 3)}, got[0].Samples)
assert.Equal(t, []prompb.Sample{s(1, 2)}, got[1].Samples)
})
}

View File

@@ -0,0 +1,78 @@
package clickhouseprometheus
import (
"context"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/prometheus"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/prometheus/common/model"
"github.com/prometheus/prometheus/model/labels"
"github.com/prometheus/prometheus/storage"
"github.com/prometheus/prometheus/storage/remote"
)
var stCallback = func() (int64, error) {
return int64(model.Latest), nil
}
type provider struct {
settings factory.ScopedProviderSettings
telemetryStore telemetrystore.TelemetryStore
engine *prometheus.Engine
parser prometheus.Parser
queryable storage.SampleAndChunkQueryable
}
func NewFactory(telemetryStore telemetrystore.TelemetryStore) factory.ProviderFactory[prometheus.Prometheus, prometheus.Config] {
return factory.NewProviderFactory(factory.MustNewName("clickhouse"), func(ctx context.Context, providerSettings factory.ProviderSettings, config prometheus.Config) (prometheus.Prometheus, error) {
return New(ctx, providerSettings, config, telemetryStore)
})
}
func New(ctx context.Context, providerSettings factory.ProviderSettings, config prometheus.Config, telemetryStore telemetrystore.TelemetryStore) (prometheus.Prometheus, error) {
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/prometheus/clickhouseprometheus")
readClient := NewReadClient(settings, telemetryStore)
return &provider{
settings: settings,
telemetryStore: telemetryStore,
engine: prometheus.NewEngine(settings.Logger(), config),
parser: prometheus.NewParser(),
queryable: remote.NewSampleAndChunkQueryableClient(readClient, labels.EmptyLabels(), []*labels.Matcher{}, false, stCallback),
}, nil
}
func (provider *provider) Engine() *prometheus.Engine {
return provider.engine
}
func (provider *provider) Parser() prometheus.Parser {
return provider.parser
}
func (provider *provider) Storage() storage.Queryable {
return provider
}
func (provider *provider) Querier(mint, maxt int64) (storage.Querier, error) {
querier, err := provider.queryable.Querier(mint, maxt)
if err != nil {
return nil, err
}
return storage.NewMergeQuerier(nil, []storage.Querier{querier}, storage.ChainedSeriesMerge), nil
}
// CapturingStorage implements prometheus.StatementCapturer. Uses a fresh
// recorder per call so concurrent dry-runs don't share state.
func (provider *provider) CapturingStorage() (storage.Queryable, prometheus.StatementRecorder) {
recorder := &statementRecorder{}
capture := &captureClient{
client: &client{settings: provider.settings, telemetryStore: provider.telemetryStore},
recorder: recorder,
}
queryable := remote.NewSampleAndChunkQueryableClient(capture, labels.EmptyLabels(), []*labels.Matcher{}, false, stCallback)
return captureQueryable{inner: queryable}, recorder
}

View File

@@ -0,0 +1,31 @@
package clickhouseprometheus
import (
"database/sql"
"fmt"
)
var _ sql.Scanner = (*scanner)(nil)
type scanner struct {
f float64
s string
}
func (s *scanner) Scan(val any) error {
s.f = 0
s.s = ""
s.s = fmt.Sprintf("%v", val)
switch val := val.(type) {
case int64:
s.f = float64(val)
case uint64:
s.f = float64(val)
case float64:
s.f = val
case []byte:
s.s = string(val)
}
return nil
}

View File

@@ -0,0 +1,41 @@
package clickhouseprometheus
import "time"
const (
databaseName string = "signoz_metrics"
distributedTimeSeriesV4 string = "distributed_time_series_v4"
distributedTimeSeriesV46hrs string = "distributed_time_series_v4_6hrs"
distributedTimeSeriesV41day string = "distributed_time_series_v4_1day"
distributedSamplesV4 string = "distributed_samples_v4"
)
var (
sixHoursInMilliseconds = time.Hour.Milliseconds() * 6
oneDayInMilliseconds = time.Hour.Milliseconds() * 24
)
// Returns the start time, end time and the table name to use for the query.
//
// If time range is less than 6 hours, we need to use the `time_series_v4` table
// else if time range is less than 1 day and greater than 6 hours, we need to use the `time_series_v4_6hrs` table
// else we need to use the `time_series_v4_1day` table
func getStartAndEndAndTableName(start, end int64) (int64, int64, string) {
var tableName string
if end-start <= sixHoursInMilliseconds {
// adjust the start time to nearest 1 hour
start = start - (start % (time.Hour.Milliseconds() * 1))
tableName = distributedTimeSeriesV4
} else if end-start <= oneDayInMilliseconds {
// adjust the start time to nearest 6 hours
start = start - (start % (time.Hour.Milliseconds() * 6))
tableName = distributedTimeSeriesV46hrs
} else {
// adjust the start time to nearest 1 day
start = start - (start % (time.Hour.Milliseconds() * 24))
tableName = distributedTimeSeriesV41day
}
return start, end, tableName
}

View File

@@ -19,7 +19,11 @@ type provider struct {
executor *executor
}
var _ prometheus.Prometheus = (*provider)(nil)
var (
_ prometheus.Prometheus = (*provider)(nil)
_ prometheus.StatementCapturer = (*provider)(nil)
_ prometheus.RangeExecutor = (*provider)(nil)
)
func NewFactory(telemetryStore telemetrystore.TelemetryStore) factory.ProviderFactory[prometheus.Prometheus, prometheus.Config] {
return factory.NewProviderFactory(factory.MustNewName("clickhousev2"), func(ctx context.Context, providerSettings factory.ProviderSettings, config prometheus.Config) (prometheus.Prometheus, error) {
@@ -43,69 +47,30 @@ func New(_ context.Context, providerSettings factory.ProviderSettings, config pr
}, nil
}
func (p *provider) QueryRange(ctx context.Context, query string, start, end time.Time, step time.Duration) (*prometheus.Result, error) {
matrix, served, err := p.executor.TryExecuteRange(ctx, query, start, end, step)
if err != nil {
return nil, err
}
if served {
return &prometheus.Result{Value: matrix}, nil
}
qry, err := p.engine.NewRangeQuery(p.traitsContext(ctx, query), p, nil, query, start, end, step)
if err != nil {
return nil, err
}
return finishQuery(ctx, qry)
func (p *provider) TryExecuteRange(ctx context.Context, query string, start, end time.Time, step time.Duration) (promql.Matrix, bool, error) {
return p.executor.TryExecuteRange(ctx, query, start, end, step)
}
func (p *provider) Query(ctx context.Context, query string, ts time.Time) (*prometheus.Result, error) {
qry, err := p.engine.NewInstantQuery(p.traitsContext(ctx, query), p, nil, query, ts)
if err != nil {
return nil, err
}
return finishQuery(ctx, qry)
func (p *provider) Engine() *prometheus.Engine {
return p.engine
}
// A fresh recorder per call keeps concurrent dry-runs isolated. Exec drives
// a Select per selector (recording SQL) but reads no data.
func (p *provider) Statements(ctx context.Context, query string, start, end time.Time, step time.Duration) ([]prometheus.CapturedStatement, error) {
recorder := &statementRecorder{}
capture := &captureQueryable{client: p.client, recorder: recorder}
qry, err := p.engine.NewRangeQuery(p.traitsContext(ctx, query), capture, nil, query, start, end, step)
if err != nil {
return nil, err
}
defer qry.Close()
if res := qry.Exec(ctx); res.Err != nil {
return nil, res.Err
}
return recorder.Statements(), nil
func (p *provider) Parser() prometheus.Parser {
return p.parser
}
// traitsContext attaches the query's traits so the storage can prove
// step-aligned optimizations safe (see prometheus.QueryTraits). A parse
// failure surfaces from the engine with its own error.
func (p *provider) traitsContext(ctx context.Context, query string) context.Context {
expr, err := p.parser.ParseExpr(query)
if err != nil {
return ctx
}
return prometheus.NewContextWithQueryTraits(ctx, prometheus.DetectQueryTraits(expr))
}
// finishQuery packages an engine evaluation. The query is closed only on
// error: Close returns the result's sample slices to the engine's pool, and
// the returned Value must stay valid for the caller.
func finishQuery(ctx context.Context, qry promql.Query) (*prometheus.Result, error) {
res := qry.Exec(ctx)
if res.Err != nil {
qry.Close()
return nil, res.Err
}
return &prometheus.Result{Value: res.Value, Warnings: res.Warnings, Stats: qry.Stats()}, nil
func (p *provider) Storage() storage.Queryable {
return p
}
func (p *provider) Querier(mint, maxt int64) (storage.Querier, error) {
return &querier{mint: mint, maxt: maxt, client: p.client}, nil
}
// CapturingStorage implements prometheus.StatementCapturer: a storage that
// records each selector's SQL without executing it, for the preview path.
// A fresh recorder per call keeps concurrent dry-runs isolated.
func (p *provider) CapturingStorage() (storage.Queryable, prometheus.StatementRecorder) {
recorder := &statementRecorder{}
return &captureQueryable{client: p.client, recorder: recorder}, recorder
}

View File

@@ -25,9 +25,8 @@ type Config struct {
// Timeout is the maximum time a query is allowed to run before being aborted.
Timeout time.Duration `mapstructure:"timeout"`
// ProviderName is retained for deployment-config compatibility. Both
// accepted values ("clickhouse", the removed v1 provider's name, and
// "clickhousev2") resolve to the clickhousev2 provider.
// ProviderName selects the storage provider: "clickhouse" (default) or
// "clickhousev2".
ProviderName string `mapstructure:"provider"`
}
@@ -43,7 +42,7 @@ func newConfig() factory.Config {
MaxConcurrent: 20,
},
Timeout: 2 * time.Minute,
ProviderName: "clickhousev2",
ProviderName: "clickhouse",
}
}
@@ -58,5 +57,8 @@ func (c Config) Validate() error {
}
func (c Config) Provider() string {
return "clickhousev2"
if c.ProviderName == "" {
return "clickhouse"
}
return c.ProviderName
}

View File

@@ -10,7 +10,6 @@ import (
promModel "github.com/prometheus/common/model"
"github.com/prometheus/prometheus/promql"
"github.com/prometheus/prometheus/promql/parser"
"github.com/prometheus/prometheus/util/stats"
"github.com/SigNoz/signoz/pkg/errors"
@@ -82,8 +81,36 @@ func (h *handler) QueryRange(w http.ResponseWriter, r *http.Request) {
}
defer cancel()
res, err := h.prom.QueryRange(ctx, r.FormValue("query"), start, end, step)
h.respondResult(ctx, w, r, res, err)
if h.tryRangeExecutor(ctx, w, r, start, end, step) {
return
}
qry, err := h.prom.Engine().NewRangeQuery(ctx, h.prom.Storage(), nil, r.FormValue("query"), start, end, step)
if err != nil {
h.respondError(r.Context(), w, errBadData, err)
return
}
h.exec(ctx, w, r, qry)
}
// tryRangeExecutor serves the query the way a RangeExecutor provider is
// designed to serve: evaluated inside the datastore when the shape allows.
// It reports whether the response was written.
func (h *handler) tryRangeExecutor(ctx context.Context, w http.ResponseWriter, r *http.Request, start, end time.Time, step time.Duration) bool {
re, ok := h.prom.(RangeExecutor)
if !ok {
return false
}
matrix, served, err := re.TryExecuteRange(ctx, r.FormValue("query"), start, end, step)
if err != nil {
h.respondError(ctx, w, errExec, err)
return true
}
if !served {
return false
}
h.respond(ctx, w, &queryData{ResultType: matrix.Type(), Result: matrix}, nil, nil)
return true
}
// Query evaluates an expression at a single instant: query and optional
@@ -107,34 +134,35 @@ func (h *handler) Query(w http.ResponseWriter, r *http.Request) {
}
defer cancel()
res, err := h.prom.Query(ctx, r.FormValue("query"), ts)
h.respondResult(ctx, w, r, res, err)
qry, err := h.prom.Engine().NewInstantQuery(ctx, h.prom.Storage(), nil, r.FormValue("query"), ts)
if err != nil {
h.respondError(r.Context(), w, errBadData, err)
return
}
h.exec(ctx, w, r, qry)
}
func (h *handler) respondResult(ctx context.Context, w http.ResponseWriter, r *http.Request, res *Result, err error) {
if err != nil {
h.logger.ErrorContext(ctx, "error evaluating promql query", errors.Attr(err))
var parseErrs parser.ParseErrors
if errors.As(err, &parseErrs) {
h.respondError(ctx, w, errBadData, err)
return
}
switch err.(type) {
func (h *handler) exec(ctx context.Context, w http.ResponseWriter, r *http.Request, qry promql.Query) {
defer qry.Close()
res := qry.Exec(ctx)
if res.Err != nil {
h.logger.ErrorContext(ctx, "error evaluating promql query", errors.Attr(res.Err))
switch res.Err.(type) {
case promql.ErrQueryCanceled:
h.respondError(ctx, w, errCanceled, err)
h.respondError(ctx, w, errCanceled, res.Err)
case promql.ErrQueryTimeout:
h.respondError(ctx, w, errTimeout, err)
h.respondError(ctx, w, errTimeout, res.Err)
case promql.ErrStorage:
h.respondError(ctx, w, errInternal, err)
h.respondError(ctx, w, errInternal, res.Err)
default:
h.respondError(ctx, w, errExec, err)
h.respondError(ctx, w, errExec, res.Err)
}
return
}
data := &queryData{ResultType: res.Value.Type(), Result: res.Value}
if r.FormValue("stats") != "" && res.Stats != nil {
data.Stats = stats.NewQueryStats(res.Stats)
if r.FormValue("stats") != "" {
data.Stats = stats.NewQueryStats(qry.Stats())
}
warnings, infos := res.Warnings.AsStrings(r.FormValue("query"), 10, 10)
h.respond(ctx, w, data, warnings, infos)

View File

@@ -6,8 +6,7 @@ import (
"github.com/prometheus/prometheus/promql"
"github.com/prometheus/prometheus/promql/parser"
"github.com/prometheus/prometheus/util/annotations"
"github.com/prometheus/prometheus/util/stats"
"github.com/prometheus/prometheus/storage"
)
type Engine = promql.Engine
@@ -15,25 +14,9 @@ type Engine = promql.Engine
type Parser = parser.Parser
type Prometheus interface {
// QueryRange evaluates a range query: inside the datastore when the
// query's shape allows it, else in the engine over the provider's
// storage, which is always exact.
QueryRange(ctx context.Context, query string, start, end time.Time, step time.Duration) (*Result, error)
// Query evaluates an instant query in the engine.
Query(ctx context.Context, query string, ts time.Time) (*Result, error)
// Statements returns the datastore statements the engine path of a
// range query would run, captured without executing them.
Statements(ctx context.Context, query string, start, end time.Time, step time.Duration) ([]CapturedStatement, error)
}
// Result is one evaluation's outcome. The caller owns Value: the provider
// never returns its sample slices to the engine's pools.
type Result struct {
Value parser.Value
Warnings annotations.Annotations
Stats *stats.Statistics
Engine() *Engine
Storage() storage.Queryable
Parser() Parser
}
// CapturedStatement is one datastore statement a PromQL query would run,
@@ -42,3 +25,33 @@ type CapturedStatement struct {
Query string
Args []any
}
// StatementRecorder reads back the statements captured against a capturing
// Storage (see StatementCapturer).
type StatementRecorder interface {
Statements() []CapturedStatement
}
// StatementCapturer is an optional Prometheus-provider capability, discovered
// via type assertion: it returns a Storage that records each Select's statement
// without executing it, plus a recorder to read them back.
type StatementCapturer interface {
CapturingStorage() (storage.Queryable, StatementRecorder)
}
// ProviderClickhouseV2 is the clickhousev2 provider name: the factory
// registration, the prometheus::provider config value and the
// X-SigNoz-PromQL-Provider request header all use it, so they cannot drift
// apart.
const ProviderClickhouseV2 = "clickhousev2"
// RangeExecutor is the optional capability of a provider that can evaluate
// some range queries entirely inside the datastore. ok=false means the
// query is not evaluable that way. The caller then runs the engine over the
// provider's Storage, which is always exact. Only the clickhousev2 provider
// implements this capability. When that provider is the only one, the
// capability folds into Prometheus itself, and the engine-vs-datastore
// decision becomes internal.
type RangeExecutor interface {
TryExecuteRange(ctx context.Context, query string, start, end time.Time, step time.Duration) (promql.Matrix, bool, error)
}

View File

@@ -5,16 +5,58 @@ import (
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/prometheus"
"github.com/SigNoz/signoz/pkg/prometheus/clickhouseprometheusv2"
"github.com/SigNoz/signoz/pkg/prometheus/clickhouseprometheus"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/prometheus/common/model"
"github.com/prometheus/prometheus/model/labels"
"github.com/prometheus/prometheus/storage"
"github.com/prometheus/prometheus/storage/remote"
)
// New returns the clickhousev2 provider over the given telemetry store, so
// tests exercise the production read path against a mock store.
func New(ctx context.Context, providerSettings factory.ProviderSettings, config prometheus.Config, telemetryStore telemetrystore.TelemetryStore) prometheus.Prometheus {
provider, err := clickhouseprometheusv2.New(ctx, providerSettings, config, telemetryStore)
if err != nil {
panic(err)
}
return provider
var _ prometheus.Prometheus = (*Provider)(nil)
type Provider struct {
queryable storage.SampleAndChunkQueryable
engine *prometheus.Engine
parser prometheus.Parser
}
var stCallback = func() (int64, error) {
return int64(model.Latest), nil
}
func New(ctx context.Context, providerSettings factory.ProviderSettings, config prometheus.Config, telemetryStore telemetrystore.TelemetryStore) *Provider {
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/prometheus/prometheustest")
engine := prometheus.NewEngine(settings.Logger(), config)
readClient := clickhouseprometheus.NewReadClient(settings, telemetryStore)
queryable := remote.NewSampleAndChunkQueryableClient(readClient, labels.EmptyLabels(), []*labels.Matcher{}, false, stCallback)
return &Provider{
engine: engine,
parser: prometheus.NewParser(),
queryable: queryable,
}
}
func (provider *Provider) Engine() *prometheus.Engine {
return provider.engine
}
func (provider *Provider) Storage() storage.Queryable {
return provider.queryable
}
func (provider *Provider) Parser() prometheus.Parser {
return provider.parser
}
func (provider *Provider) Close() error {
if provider.engine != nil {
provider.engine.Close()
}
return nil
}

View File

@@ -57,6 +57,7 @@ func (handler *handler) QueryRange(rw http.ResponseWriter, req *http.Request) {
render.Error(rw, err)
return
}
queryRangeRequest.PromQLProvider = req.Header.Get("X-SigNoz-PromQL-Provider")
// Validate the query request
if err := queryRangeRequest.Validate(); err != nil {

View File

@@ -231,7 +231,7 @@ func (q *querier) buildPreviewProviders(
sub.CompositeQuery = qbtypes.CompositeQuery{Queries: []qbtypes.QueryEnvelope{query}}
}
built, _, bErr := q.buildQueries(orgID, &sub, deps, missingMetricQuerySet, event)
built, _, bErr := q.buildQueries(orgID, &sub, deps, missingMetricQuerySet, event, promqlOptions{})
if bErr != nil {
errs[name] = bErr
continue

View File

@@ -102,6 +102,24 @@ type promqlQuery struct {
tr qbv5.TimeRange
requestType qbv5.RequestType
vars map[string]qbv5.VariableItem
opts promqlOptions
}
// promqlOptions is how a PromQL query relates to the clickhousev2 provider
// (see querier.promqlOptions for where the fields come from and why they are
// flag-gated). Both providers are nil for a plain request, so a plain
// request costs nothing extra.
type promqlOptions struct {
// shadow, when set, runs the query on this provider after serving and
// logs any result difference; the response is never affected.
shadow prometheus.Prometheus
// shadowSlots is the querier-wide admission for shadow runs, shared by
// every query so the bound holds per process.
shadowSlots chan struct{}
// serve, when set, serves the response from this provider instead of the
// default path. Comparison callers fetch the default and the pinned
// result as two API calls and diff them.
serve prometheus.Prometheus
}
var _ qbv5.Query = (*promqlQuery)(nil)
@@ -114,19 +132,29 @@ func newPromqlQuery(
tr qbv5.TimeRange,
requestType qbv5.RequestType,
variables map[string]qbv5.VariableItem,
opts promqlOptions,
) *promqlQuery {
return &promqlQuery{
logger: logger,
promEngine: promEngine,
parser: prometheus.NewParser(),
parser: promEngine.Parser(),
query: query,
tr: tr,
requestType: requestType,
vars: variables,
opts: opts,
}
}
func (q *promqlQuery) Fingerprint() string {
// A pinned request must not share cache entries with default serving: a
// cached default result would satisfy the pin without running the pinned
// provider, and a pinned result would poison normal serving. No
// fingerprint means no caching at all — the pin exists to observe a
// provider, so a cache in front of it defeats the point.
if q.opts.serve != nil {
return ""
}
if q.requestType != qbv5.RequestTypeTimeSeries {
return ""
}
@@ -239,9 +267,15 @@ func (q *promqlQuery) Statement(_ context.Context) (*qbv5.Statement, error) {
return &qbv5.Statement{Query: rendered}, nil
}
// PreviewStatements returns the ClickHouse statement(s) this PromQL query
// would run on the engine path, captured without executing them.
// PreviewStatements returns the ClickHouse statement(s) this PromQL query would
// run, captured by driving the engine with a Storage that records each selector's
// SQL and returns no data. Returns nil if capture is unsupported.
func (q *promqlQuery) PreviewStatements(ctx context.Context) ([]prometheus.CapturedStatement, error) {
storer, ok := q.promEngine.(prometheus.StatementCapturer)
if !ok {
return nil, nil
}
rendered, err := q.renderVars(q.query.Query, q.vars, q.tr.From, q.tr.To)
if err != nil {
return nil, err
@@ -250,11 +284,42 @@ func (q *promqlQuery) PreviewStatements(ctx context.Context) ([]prometheus.Captu
start := int64(querybuilder.ToNanoSecs(q.tr.From))
end := int64(querybuilder.ToNanoSecs(q.tr.To))
statements, err := q.promEngine.Statements(ctx, rendered, time.Unix(0, start), time.Unix(0, end), q.query.Step.Duration)
if err != nil {
return nil, q.evalError(rendered, err)
// Attach the same query traits as Execute so the captured statements
// match what the live path would run.
if expr, parseErr := q.parser.ParseExpr(rendered); parseErr == nil {
ctx = prometheus.NewContextWithQueryTraits(ctx, prometheus.DetectQueryTraits(expr))
}
return statements, nil
capStorage, recorder := storer.CapturingStorage()
if capStorage == nil {
return nil, nil
}
qry, err := q.promEngine.Engine().NewRangeQuery(
ctx,
capStorage,
nil,
rendered,
time.Unix(0, start),
time.Unix(0, end),
q.query.Step.Duration,
)
if err != nil {
if e := tryEnhancePromQLExecError(err); e != nil {
return nil, e
}
return nil, enhancePromQLError(rendered, err)
}
defer qry.Close()
// Exec drives a Select per selector (recording SQL) but reads no data.
if res := qry.Exec(ctx); res.Err != nil {
if e := tryEnhancePromQLExecError(res.Err); e != nil {
return nil, e
}
return nil, errors.Newf(errors.TypeInternal, errors.CodeInternal, "query execution error: %v", res.Err)
}
return recorder.Statements(), nil
}
func (q *promqlQuery) Execute(ctx context.Context) (*qbv5.Result, error) {
@@ -272,6 +337,13 @@ func (q *promqlQuery) Execute(ctx context.Context) (*qbv5.Result, error) {
return nil, err
}
// Attach query traits so the storage can prove step-aligned optimizations
// safe (see prometheus.QueryTraits). A parse failure surfaces below via
// the engine with the enhanced error message.
if expr, parseErr := q.parser.ParseExpr(query); parseErr == nil {
ctx = prometheus.NewContextWithQueryTraits(ctx, prometheus.DetectQueryTraits(expr))
}
// Accumulate ClickHouse-side scan stats across every storage query this
// evaluation issues (engine selectors or the compiled executor): progress
// options propagate to each ClickHouse query through the context.
@@ -286,36 +358,97 @@ func (q *promqlQuery) Execute(ctx context.Context) (*qbv5.Result, error) {
began := time.Now()
res, err := q.promEngine.QueryRange(ctx, query, time.Unix(0, start), time.Unix(0, end), q.query.Step.Duration)
if err != nil {
return nil, q.evalError(query, err)
// A pinned provider serves directly from it: comparison callers fetch
// the default result and the pinned result as two API calls and diff
// them.
if q.opts.serve != nil {
matrix, err := q.serveFromProvider(ctx, query, start, end)
if err != nil {
if enhanced := tryEnhancePromQLExecError(err); enhanced != nil {
return nil, enhanced
}
return nil, err
}
return q.toResult(matrix, nil, began, &statsMu, &rowsScanned, &bytesScanned), nil
}
matrix, ok := res.Value.(promql.Matrix)
if !ok {
return nil, errors.Newf(errors.TypeInternal, errors.CodeInternal, "promql query %q returned %T, expected a matrix", query, res.Value)
// When the serving provider has the RangeExecutor capability
// (prometheus::provider: clickhousev2), serve the way the provider is
// designed to serve: transpiled when the shape allows. Without this the
// override would silently run the engine path only.
if re, ok := q.promEngine.(prometheus.RangeExecutor); ok {
matrix, served, err := re.TryExecuteRange(ctx, query, time.Unix(0, start), time.Unix(0, end), q.query.Step.Duration)
if err != nil {
if enhanced := tryEnhancePromQLExecError(err); enhanced != nil {
return nil, enhanced
}
return nil, err
}
if served {
return q.toResult(matrix, nil, began, &statsMu, &rowsScanned, &bytesScanned), nil
}
}
qry, err := q.promEngine.Engine().NewRangeQuery(
ctx,
q.promEngine.Storage(),
nil,
query,
time.Unix(0, start),
time.Unix(0, end),
q.query.Step.Duration,
)
if err != nil {
// NewRangeQuery can fail with execution errors (e.g. context deadline exceeded)
// during the query queue/scheduling stage, not just parse errors.
if err := tryEnhancePromQLExecError(err); err != nil {
return nil, err
}
return nil, enhancePromQLError(query, err)
}
res := qry.Exec(ctx)
if res.Err != nil {
if err := tryEnhancePromQLExecError(res.Err); err != nil {
return nil, err
}
return nil, errors.Newf(errors.TypeInternal, errors.CodeInternal, "query execution error: %v", res.Err)
}
defer qry.Close()
matrix, promErr := res.Matrix()
if promErr != nil {
return nil, errors.WrapInternalf(promErr, errors.CodeInternal, "error getting matrix from promql query %q", query)
}
if q.opts.shadow != nil {
// Shadows detach from the request, so without admission a dashboard
// burst would stack unbounded ClickHouse work for up to the shadow
// timeout — the concurrency pattern behind the original outages.
// Non-blocking: at the cap the comparison is skipped, not queued;
// a sampled shadow stream is exactly as useful for rollout evidence.
select {
case q.opts.shadowSlots <- struct{}{}:
// The engine pools the result's sample slices on Close; the
// shadow comparison needs a stable copy of what was served.
served := copyMatrix(matrix)
servedIn := time.Since(began)
go func() {
defer func() { <-q.opts.shadowSlots }()
q.runShadowCompare(context.WithoutCancel(ctx), query, start, end, served, servedIn)
}()
default:
q.logger.DebugContext(ctx, "promql shadow skipped: at concurrency cap", slog.String("query", query))
}
}
warnings, _ := res.Warnings.AsStrings(query, 10, 0)
return q.toResult(matrix, warnings, began, &statsMu, &rowsScanned, &bytesScanned), nil
}
// evalError types an evaluation error: engine execution classes first, then
// parse errors with the migration hints, everything else internal.
func (q *promqlQuery) evalError(query string, err error) error {
if enhanced := tryEnhancePromQLExecError(err); enhanced != nil {
return enhanced
}
var parseErrs parser.ParseErrors
if errors.As(err, &parseErrs) {
return enhancePromQLError(query, err)
}
if errors.Ast(err, errors.TypeInvalidInput) {
return err
}
return errors.Newf(errors.TypeInternal, errors.CodeInternal, "query execution error: %v", err)
}
// toResult converts an evaluated matrix into the v5 result shape, attaching
// the ClickHouse scan stats accumulated during evaluation.
func (q *promqlQuery) toResult(matrix promql.Matrix, warnings []string, began time.Time, statsMu *sync.Mutex, rowsScanned, bytesScanned *uint64) *qbv5.Result {

View File

@@ -13,6 +13,7 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/prometheus"
"github.com/SigNoz/signoz/pkg/prometheus/prometheustest"
qbv5 "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -448,6 +449,18 @@ func TestQuotedMetricOutsideBracesPattern(t *testing.T) {
}
}
// A pinned request must not share cache entries with default serving: a
// cached default result would satisfy the pin without running the pinned
// provider.
func TestFingerprint_PinnedProviderBypassesCache(t *testing.T) {
q := &promqlQuery{
logger: slog.Default(),
query: qbv5.PromQuery{Query: "up"},
opts: promqlOptions{serve: &prometheustest.Provider{}},
}
assert.Empty(t, q.Fingerprint())
}
func TestToResultDropsNonFiniteValues(t *testing.T) {
tests := []struct {
description string

View File

@@ -0,0 +1,183 @@
package querier
import (
"context"
"fmt"
"log/slog"
"math"
"sort"
"time"
"github.com/ClickHouse/clickhouse-go/v2"
"github.com/SigNoz/signoz/pkg/prometheus"
"github.com/prometheus/prometheus/model/labels"
"github.com/prometheus/prometheus/promql"
)
// shadowTimeout bounds a shadow evaluation; a shadow run must never outlive
// the request by much or pile up.
const shadowTimeout = 2 * time.Minute
// runShadowCompare executes the query on the clickhousev2 provider exactly
// as it would serve (transpiled when the shape allows, engine over the v2
// querier otherwise), compares against the served result and logs the
// outcome. Serving is never affected: this runs after the response, off the
// request context, and only logs. The mismatch and failure logs are the
// rollout evidence — serving cuts over to v2 only after they stay clean.
func (q *promqlQuery) runShadowCompare(ctx context.Context, query string, startNs, endNs int64, served promql.Matrix, servedIn time.Duration) {
defer func() {
if r := recover(); r != nil {
q.logger.ErrorContext(ctx, "promql shadow comparison panicked", slog.Any("panic", r), slog.String("query", query))
}
}()
ctx, cancel := context.WithTimeout(ctx, shadowTimeout)
defer cancel()
// The request context carries the served response's scan-stats progress
// callback; without replacing it the shadow's ClickHouse progress would
// race into the served stats. The response itself was already sent.
ctx = clickhouse.Context(ctx, clickhouse.WithProgress(func(*clickhouse.Progress) {}))
if expr, parseErr := q.parser.ParseExpr(query); parseErr == nil {
ctx = prometheus.NewContextWithQueryTraits(ctx, prometheus.DetectQueryTraits(expr))
}
start, end := time.Unix(0, startNs), time.Unix(0, endNs)
began := time.Now()
shadow, transpiled, err := executeOnProvider(ctx, q.opts.shadow, query, start, end, q.query.Step.Duration)
shadowIn := time.Since(began)
logAttrs := []any{
slog.String("query", query),
slog.Int64("start_ms", startNs/int64(time.Millisecond)),
slog.Int64("end_ms", endNs/int64(time.Millisecond)),
slog.Duration("step", q.query.Step.Duration),
slog.Bool("transpiled", transpiled),
slog.Duration("served_in", servedIn),
slog.Duration("shadow_in", shadowIn),
}
if err != nil {
// A shadow failure would be a serving failure after rollout; surface
// it at the same level as a result mismatch.
q.logger.WarnContext(ctx, "promql shadow execution failed", append(logAttrs, slog.Any("error", err))...)
return
}
servedNorm := normalizeShadowMatrix(served)
shadowNorm := normalizeShadowMatrix(shadow)
if diff := diffShadowMatrices(servedNorm, shadowNorm); diff != "" {
q.logger.WarnContext(ctx, "promql shadow comparison mismatch", append(logAttrs,
slog.String("diff", diff),
slog.Int("served_series", len(servedNorm)),
slog.Int("shadow_series", len(shadowNorm)),
)...)
return
}
// Matches log the timings: served_in vs shadow_in across the fleet is
// the perf evidence for the cutover, gathered for free.
q.logger.DebugContext(ctx, "promql shadow comparison matched", logAttrs...)
}
func (q *promqlQuery) serveFromProvider(ctx context.Context, query string, startNs, endNs int64) (promql.Matrix, error) {
matrix, _, err := executeOnProvider(ctx, q.opts.serve, query, time.Unix(0, startNs), time.Unix(0, endNs), q.query.Step.Duration)
return matrix, err
}
// The returned matrix is an owned copy.
func executeOnProvider(ctx context.Context, prov prometheus.Prometheus, query string, start, end time.Time, step time.Duration) (promql.Matrix, bool, error) {
if re, ok := prov.(prometheus.RangeExecutor); ok {
matrix, served, err := re.TryExecuteRange(ctx, query, start, end, step)
if err != nil {
return nil, true, err
}
if served {
return matrix, true, nil
}
}
qry, err := prov.Engine().NewRangeQuery(ctx, prov.Storage(), nil, query, start, end, step)
if err != nil {
return nil, false, err
}
defer qry.Close()
res := qry.Exec(ctx)
if res.Err != nil {
return nil, false, res.Err
}
matrix, err := res.Matrix()
if err != nil {
return nil, false, err
}
// Close returns the result's sample slices to the engine pool.
return copyMatrix(matrix), false, nil
}
func copyMatrix(matrix promql.Matrix) promql.Matrix {
out := make(promql.Matrix, 0, len(matrix))
for _, s := range matrix {
floats := make([]promql.FPoint, len(s.Floats))
copy(floats, s.Floats)
out = append(out, promql.Series{Metric: s.Metric.Copy(), Floats: floats})
}
return out
}
// normalizeShadowMatrix sorts by label set for order-independent
// comparison. Both providers now resolve series identity the same way
// (empty-valued labels dropped at read, no synthetic fingerprint label
// since the v1 series-identity fix), so labels need no normalization.
func normalizeShadowMatrix(matrix promql.Matrix) promql.Matrix {
out := make(promql.Matrix, 0, len(matrix))
out = append(out, matrix...)
sort.Slice(out, func(i, j int) bool { return labels.Compare(out[i].Metric, out[j].Metric) < 0 })
return out
}
// diffShadowMatrices returns a description of the first difference, or "".
// Values compare with relative tolerance: spatial aggregations accumulate
// floats in storage order, which differs between the providers in the last
// ULP.
func diffShadowMatrices(served, shadow promql.Matrix) string {
const relTol = 1e-9
if len(served) != len(shadow) {
return fmt.Sprintf("series count: served=%d shadow=%d", len(served), len(shadow))
}
for i := range served {
if labels.Compare(served[i].Metric, shadow[i].Metric) != 0 {
return fmt.Sprintf("series %d labels: served=%s shadow=%s", i, served[i].Metric, shadow[i].Metric)
}
if len(served[i].Floats) != len(shadow[i].Floats) {
return fmt.Sprintf("series %s points: served=%d shadow=%d", served[i].Metric, len(served[i].Floats), len(shadow[i].Floats))
}
for j := range served[i].Floats {
a, b := served[i].Floats[j], shadow[i].Floats[j]
if a.T != b.T {
return fmt.Sprintf("series %s point %d ts: served=%d shadow=%d", served[i].Metric, j, a.T, b.T)
}
// NaN and infinities first: NaN != NaN and Inf-Inf arithmetic
// would otherwise make one-sided NaN and Inf-vs-finite compare
// as equal (NaN > x and Inf > Inf are both false).
if math.IsNaN(a.F) || math.IsNaN(b.F) {
if math.IsNaN(a.F) != math.IsNaN(b.F) {
return fmt.Sprintf("series %s @%d value: served=%v shadow=%v", served[i].Metric, a.T, a.F, b.F)
}
continue
}
if math.IsInf(a.F, 0) || math.IsInf(b.F, 0) {
if a.F != b.F {
return fmt.Sprintf("series %s @%d value: served=%v shadow=%v", served[i].Metric, a.T, a.F, b.F)
}
continue
}
diff := math.Abs(a.F - b.F)
scale := math.Max(math.Abs(a.F), math.Abs(b.F))
if diff > relTol*math.Max(scale, 1e-300) && diff > 1e-12 {
return fmt.Sprintf("series %s @%d value: served=%v shadow=%v", served[i].Metric, a.T, a.F, b.F)
}
}
}
return ""
}

View File

@@ -0,0 +1,67 @@
package querier
import (
"math"
"testing"
"github.com/prometheus/prometheus/model/labels"
"github.com/prometheus/prometheus/promql"
"github.com/stretchr/testify/assert"
)
func TestNormalizeShadowMatrix(t *testing.T) {
matrix := promql.Matrix{
{
Metric: labels.FromStrings("__name__", "up", "job", "api"),
Floats: []promql.FPoint{{T: 1000, F: 1}},
},
{
Metric: labels.FromStrings("a", "1"),
Floats: []promql.FPoint{{T: 1000, F: 2}},
},
}
norm := normalizeShadowMatrix(matrix)
// sorted by label set; labels pass through untouched — both providers
// resolve series identity identically since the v1 series-identity fix
assert.Equal(t, labels.FromStrings("__name__", "up", "job", "api"), norm[0].Metric)
assert.Equal(t, labels.FromStrings("a", "1"), norm[1].Metric)
}
func TestDiffShadowMatrices(t *testing.T) {
series := func(v float64) promql.Matrix {
return promql.Matrix{{Metric: labels.FromStrings("a", "1"), Floats: []promql.FPoint{{T: 1000, F: v}}}}
}
assert.Empty(t, diffShadowMatrices(series(1.5), series(1.5)))
// last-ULP differences from storage-order float accumulation are expected
assert.Empty(t, diffShadowMatrices(series(0.08888888888888889), series(0.08888888888888888)))
assert.Empty(t, diffShadowMatrices(series(math.NaN()), series(math.NaN())))
assert.Contains(t, diffShadowMatrices(series(1.5), series(1.6)), "value")
assert.Contains(t, diffShadowMatrices(series(1.5), promql.Matrix{}), "series count")
assert.Contains(t, diffShadowMatrices(
series(1.5),
promql.Matrix{{Metric: labels.FromStrings("a", "2"), Floats: []promql.FPoint{{T: 1000, F: 1.5}}}},
), "labels")
assert.Contains(t, diffShadowMatrices(
series(1.5),
promql.Matrix{{Metric: labels.FromStrings("a", "1"), Floats: []promql.FPoint{{T: 2000, F: 1.5}}}},
), "ts")
}
// One-sided NaN makes every float comparison false, and Inf-Inf arithmetic
// yields Inf > Inf == false; without explicit handling both divergences log
// as matched — a shadow comparator that cannot see them would green-light a
// broken rollout.
func TestDiffShadowMatrices_SpecialFloats(t *testing.T) {
point := func(v float64) promql.Matrix {
return promql.Matrix{{Metric: labels.FromStrings("a", "1"), Floats: []promql.FPoint{{T: 1000, F: v}}}}
}
assert.NotEmpty(t, diffShadowMatrices(point(math.NaN()), point(1.5)), "one-sided NaN must diff")
assert.NotEmpty(t, diffShadowMatrices(point(1.5), point(math.NaN())), "one-sided NaN must diff either way")
assert.NotEmpty(t, diffShadowMatrices(point(math.Inf(1)), point(1.5)), "Inf vs finite must diff")
assert.NotEmpty(t, diffShadowMatrices(point(math.Inf(1)), point(math.Inf(-1))), "opposite infinities must diff")
assert.Empty(t, diffShadowMatrices(point(math.Inf(1)), point(math.Inf(1))), "equal infinities match")
assert.Empty(t, diffShadowMatrices(point(math.NaN()), point(math.NaN())), "both NaN match")
}

View File

@@ -24,6 +24,7 @@ import (
"github.com/SigNoz/signoz/pkg/statsreporter"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
"github.com/SigNoz/signoz/pkg/types/featuretypes"
"github.com/SigNoz/signoz/pkg/types/instrumentationtypes"
"github.com/SigNoz/signoz/pkg/types/metrictypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
@@ -46,11 +47,19 @@ type Querier interface {
}
type querier struct {
logger *slog.Logger
fl flagger.Flagger
telemetryStore telemetrystore.TelemetryStore
metadataStore telemetrytypes.MetadataStore
promEngine prometheus.Prometheus
logger *slog.Logger
fl flagger.Flagger
telemetryStore telemetrystore.TelemetryStore
metadataStore telemetrytypes.MetadataStore
promEngine prometheus.Prometheus
// promV2 is the clickhousev2 prometheus provider, wired only when the
// serving provider is the default one (nil otherwise). It reads the same
// ClickHouse data through a different implementation; PromQL queries
// shadow-compare against it behind the use_prometheus_clickhouse_v2 flag
// and can be pinned to it for a response (see promqlOptions). It never
// serves by default — that cutover happens only after the shadow logs
// stay clean.
promV2 prometheus.Prometheus
traceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation]
aiTraceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation]
logStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation]
@@ -62,8 +71,16 @@ type querier struct {
liveDataRefresh time.Duration
builderConfig builderConfig
maxConcurrentQueries int
// shadowSlots bounds concurrent shadow comparisons per process; shadows
// detach from their requests, so nothing else limits how many pile up.
shadowSlots chan struct{}
}
// maxConcurrentShadows is deliberately small: a shadow is a full extra
// ClickHouse evaluation, and a sampled stream of comparisons is exactly as
// useful for rollout evidence as an exhaustive one under load.
const maxConcurrentShadows = 8
var _ Querier = (*querier)(nil)
func New(
@@ -71,6 +88,7 @@ func New(
telemetryStore telemetrystore.TelemetryStore,
metadataStore telemetrytypes.MetadataStore,
promEngine prometheus.Prometheus,
promV2 prometheus.Prometheus,
traceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation],
aiTraceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation],
logStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation],
@@ -93,6 +111,7 @@ func New(
telemetryStore: telemetryStore,
metadataStore: metadataStore,
promEngine: promEngine,
promV2: promV2,
traceStmtBuilder: traceStmtBuilder,
aiTraceStmtBuilder: aiTraceStmtBuilder,
logStmtBuilder: logStmtBuilder,
@@ -106,6 +125,7 @@ func New(
logTraceIDWindowPaddingMS: uint64(logTraceIDWindowPadding.Milliseconds()),
},
maxConcurrentQueries: maxConcurrentQueries,
shadowSlots: make(chan struct{}, maxConcurrentShadows),
}
}
@@ -145,7 +165,11 @@ func (q *querier) QueryRange(ctx context.Context, orgID valuer.UUID, req *qbtype
missingMetricQuerySet[name] = true
}
queries, steps, err := q.buildQueries(orgID, req, dependencyQueries, missingMetricQuerySet, event)
promqlOpts, err := q.promqlOptions(ctx, orgID, req)
if err != nil {
return nil, err
}
queries, steps, err := q.buildQueries(orgID, req, dependencyQueries, missingMetricQuerySet, event, promqlOpts)
if err != nil {
return nil, err
}
@@ -188,12 +212,41 @@ func (q *querier) QueryRange(ctx context.Context, orgID valuer.UUID, req *qbtype
return qbResp, qbErr
}
// promqlOptions derives the PromQL execution options for a request. With the
// org's use_prometheus_clickhouse_v2 flag on, queries are shadow-compared
// against the clickhousev2 provider (serving unaffected, diffs logged; see
// promql_shadow.go). The X-SigNoz-PromQL-Provider header may instead pin the
// response to that provider — integration tests and support fetch both
// results for comparison — so it is deliberately flag-gated too: without the
// gate the header would be an unaudited switch onto a provider still under
// validation.
func (q *querier) promqlOptions(ctx context.Context, orgID valuer.UUID, req *qbtypes.QueryRangeRequest) (promqlOptions, error) {
enabled := q.fl.BooleanOrEmpty(ctx, flagger.FeatureUsePrometheusClickhouseV2, featuretypes.NewFlaggerEvaluationContext(orgID))
if req.PromQLProvider == "" {
if enabled && q.promV2 != nil {
return promqlOptions{shadow: q.promV2, shadowSlots: q.shadowSlots}, nil
}
return promqlOptions{}, nil
}
if req.PromQLProvider != prometheus.ProviderClickhouseV2 {
return promqlOptions{}, errors.NewInvalidInputf(errors.CodeInvalidInput, "unknown promql provider %q", req.PromQLProvider)
}
if !enabled {
return promqlOptions{}, errors.NewInvalidInputf(errors.CodeInvalidInput, "promql provider %q requires the use_prometheus_clickhouse_v2 flag", req.PromQLProvider)
}
if q.promV2 == nil {
return promqlOptions{}, errors.NewInvalidInputf(errors.CodeInvalidInput, "promql provider %q is not available", req.PromQLProvider)
}
return promqlOptions{serve: q.promV2}, nil
}
func (q *querier) buildQueries(
orgID valuer.UUID,
req *qbtypes.QueryRangeRequest,
dependencyQueries map[string]bool,
missingMetricQuerySet map[string]bool,
event *qbtypes.QBEvent,
promqlOpts promqlOptions,
) (map[string]qbtypes.Query, map[string]qbtypes.Step, error) {
tmplVars := req.Variables
@@ -218,7 +271,7 @@ func (q *querier) buildQueries(
if !ok {
return nil, nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid promql query spec %T", query.Spec)
}
promqlQuery := newPromqlQuery(q.logger, q.promEngine, promQuery, qbtypes.TimeRange{From: req.Start, To: req.End}, req.RequestType, tmplVars)
promqlQuery := newPromqlQuery(q.logger, q.promEngine, promQuery, qbtypes.TimeRange{From: req.Start, To: req.End}, req.RequestType, tmplVars, promqlOpts)
queries[promQuery.Name] = promqlQuery
steps[promQuery.Name] = promQuery.Step
case qbtypes.QueryTypeClickHouseSQL:
@@ -876,7 +929,7 @@ func (q *querier) createRangedQuery(_ valuer.UUID, originalQuery qbtypes.Query,
switch qt := originalQuery.(type) {
case *promqlQuery:
queryCopy := qt.query.Copy()
return newPromqlQuery(q.logger, qt.promEngine, queryCopy, timeRange, qt.requestType, qt.vars)
return newPromqlQuery(q.logger, qt.promEngine, queryCopy, timeRange, qt.requestType, qt.vars, qt.opts)
case *chSQLQuery:
queryCopy := qt.query.Copy()

View File

@@ -48,6 +48,7 @@ func TestQueryRange_MetricTypeMissing(t *testing.T) {
nil, // telemetryStore
metadataStore,
nil, // prometheus
nil, // promV2
nil, // traceStmtBuilder
nil, // aiTraceStmtBuilder
nil, // logStmtBuilder
@@ -121,6 +122,7 @@ func TestQueryRange_MetricTypeFromStore(t *testing.T) {
telemetryStore,
metadataStore,
nil, // prometheus
nil, // promV2
nil, // traceStmtBuilder
nil, // aiTraceStmtBuilder
nil, // logStmtBuilder

View File

@@ -18,6 +18,7 @@ import (
func NewFactory(
telemetryStore telemetrystore.TelemetryStore,
prometheus prometheus.Prometheus,
promV2 prometheus.Prometheus,
metadataStore telemetrytypes.MetadataStore,
traceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation],
aiTraceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation],
@@ -41,6 +42,7 @@ func NewFactory(
telemetryStore,
metadataStore,
prometheus,
promV2,
traceStmtBuilder,
aiTraceStmtBuilder,
logStmtBuilder,

View File

@@ -35,7 +35,6 @@ import (
errorsV2 "github.com/SigNoz/signoz/pkg/errors"
"github.com/prometheus/prometheus/promql"
"github.com/prometheus/prometheus/promql/parser"
"github.com/prometheus/prometheus/util/stats"
"github.com/ClickHouse/clickhouse-go/v2"
@@ -231,43 +230,41 @@ func NewReader(
}
func (r *ClickHouseReader) GetInstantQueryMetricsResult(ctx context.Context, queryParams *model.InstantQueryMetricsParams) (*promql.Result, *stats.QueryStats, *model.ApiError) {
res, err := r.prometheus.Query(ctx, queryParams.Query, queryParams.Time)
var qs stats.QueryStats
qry, err := r.prometheus.Engine().NewInstantQuery(ctx, r.prometheus.Storage(), nil, queryParams.Query, queryParams.Time)
if err != nil {
var parseErrs parser.ParseErrors
if errorsV2.As(err, &parseErrs) {
return nil, nil, &model.ApiError{Typ: model.ErrorBadData, Err: err}
}
// Evaluation errors travel inside the result, as the engine reports
// them; the handler maps them from there.
return &promql.Result{Err: err}, &qs, nil
return nil, nil, &model.ApiError{Typ: model.ErrorBadData, Err: err}
}
res := qry.Exec(ctx)
// Optional stats field in response if parameter "stats" is not empty.
if queryParams.Stats != "" && res.Stats != nil {
qs = stats.NewQueryStats(res.Stats)
var qs stats.QueryStats
if queryParams.Stats != "" {
qs = stats.NewQueryStats(qry.Stats())
}
return &promql.Result{Value: res.Value, Warnings: res.Warnings}, &qs, nil
qry.Close()
return res, &qs, nil
}
func (r *ClickHouseReader) GetQueryRangeResult(ctx context.Context, query *model.QueryRangeParams) (*promql.Result, *stats.QueryStats, *model.ApiError) {
res, err := r.prometheus.QueryRange(ctx, query.Query, query.Start, query.End, query.Step)
var qs stats.QueryStats
qry, err := r.prometheus.Engine().NewRangeQuery(ctx, r.prometheus.Storage(), nil, query.Query, query.Start, query.End, query.Step)
if err != nil {
var parseErrs parser.ParseErrors
if errorsV2.As(err, &parseErrs) {
return nil, nil, &model.ApiError{Typ: model.ErrorBadData, Err: err}
}
return &promql.Result{Err: err}, &qs, nil
return nil, nil, &model.ApiError{Typ: model.ErrorBadData, Err: err}
}
res := qry.Exec(ctx)
// Optional stats field in response if parameter "stats" is not empty.
if query.Stats != "" && res.Stats != nil {
qs = stats.NewQueryStats(res.Stats)
var qs stats.QueryStats
if query.Stats != "" {
qs = stats.NewQueryStats(qry.Stats())
}
return &promql.Result{Value: res.Value, Warnings: res.Warnings}, &qs, nil
qry.Close()
return res, &qs, nil
}
func (r *ClickHouseReader) GetServicesList(ctx context.Context) (*[]string, error) {

View File

@@ -4070,20 +4070,20 @@ func (aH *APIHandler) RegisterTraceFunnelsRoutes(router *mux.Router, am *middlew
Methods(http.MethodPut)
// Analytics endpoints
traceFunnelsRouter.HandleFunc("/{funnel_id}/analytics/validate", am.ViewAccess(aH.handleValidateTraces)).Methods("POST")
traceFunnelsRouter.HandleFunc("/{funnel_id}/analytics/overview", am.ViewAccess(aH.handleFunnelAnalytics)).Methods("POST")
traceFunnelsRouter.HandleFunc("/{funnel_id}/analytics/steps", am.ViewAccess(aH.handleStepAnalytics)).Methods("POST")
traceFunnelsRouter.HandleFunc("/{funnel_id}/analytics/steps/overview", am.ViewAccess(aH.handleFunnelStepAnalytics)).Methods("POST")
traceFunnelsRouter.HandleFunc("/{funnel_id}/analytics/slow-traces", am.ViewAccess(aH.handleFunnelSlowTraces)).Methods("POST")
traceFunnelsRouter.HandleFunc("/{funnel_id}/analytics/error-traces", am.ViewAccess(aH.handleFunnelErrorTraces)).Methods("POST")
traceFunnelsRouter.HandleFunc("/{funnel_id}/analytics/validate", aH.handleValidateTraces).Methods("POST")
traceFunnelsRouter.HandleFunc("/{funnel_id}/analytics/overview", aH.handleFunnelAnalytics).Methods("POST")
traceFunnelsRouter.HandleFunc("/{funnel_id}/analytics/steps", aH.handleStepAnalytics).Methods("POST")
traceFunnelsRouter.HandleFunc("/{funnel_id}/analytics/steps/overview", aH.handleFunnelStepAnalytics).Methods("POST")
traceFunnelsRouter.HandleFunc("/{funnel_id}/analytics/slow-traces", aH.handleFunnelSlowTraces).Methods("POST")
traceFunnelsRouter.HandleFunc("/{funnel_id}/analytics/error-traces", aH.handleFunnelErrorTraces).Methods("POST")
// Analytics endpoints
traceFunnelsRouter.HandleFunc("/analytics/validate", am.ViewAccess(aH.handleValidateTracesWithPayload)).Methods("POST")
traceFunnelsRouter.HandleFunc("/analytics/overview", am.ViewAccess(aH.handleFunnelAnalyticsWithPayload)).Methods("POST")
traceFunnelsRouter.HandleFunc("/analytics/steps", am.ViewAccess(aH.handleStepAnalyticsWithPayload)).Methods("POST")
traceFunnelsRouter.HandleFunc("/analytics/steps/overview", am.ViewAccess(aH.handleFunnelStepAnalyticsWithPayload)).Methods("POST")
traceFunnelsRouter.HandleFunc("/analytics/slow-traces", am.ViewAccess(aH.handleFunnelSlowTracesWithPayload)).Methods("POST")
traceFunnelsRouter.HandleFunc("/analytics/error-traces", am.ViewAccess(aH.handleFunnelErrorTracesWithPayload)).Methods("POST")
traceFunnelsRouter.HandleFunc("/analytics/validate", aH.handleValidateTracesWithPayload).Methods("POST")
traceFunnelsRouter.HandleFunc("/analytics/overview", aH.handleFunnelAnalyticsWithPayload).Methods("POST")
traceFunnelsRouter.HandleFunc("/analytics/steps", aH.handleStepAnalyticsWithPayload).Methods("POST")
traceFunnelsRouter.HandleFunc("/analytics/steps/overview", aH.handleFunnelStepAnalyticsWithPayload).Methods("POST")
traceFunnelsRouter.HandleFunc("/analytics/slow-traces", aH.handleFunnelSlowTracesWithPayload).Methods("POST")
traceFunnelsRouter.HandleFunc("/analytics/error-traces", aH.handleFunnelErrorTracesWithPayload).Methods("POST")
}
func (aH *APIHandler) handleValidateTraces(w http.ResponseWriter, r *http.Request) {

View File

@@ -2,9 +2,19 @@ package app
import (
"context"
"fmt"
"net"
"net/http"
"slices"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/queryparser"
"github.com/gorilla/handlers"
"github.com/rs/cors"
"github.com/soheilhy/cmux"
"github.com/SigNoz/signoz/pkg/http/middleware"
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
"github.com/SigNoz/signoz/pkg/query-service/app/clickhouseReader"
@@ -13,15 +23,31 @@ import (
"github.com/SigNoz/signoz/pkg/query-service/app/opamp"
opAmpModel "github.com/SigNoz/signoz/pkg/query-service/app/opamp/model"
"github.com/SigNoz/signoz/pkg/signoz"
"github.com/SigNoz/signoz/pkg/web"
"log/slog"
"go.opentelemetry.io/contrib/instrumentation/github.com/gorilla/mux/otelmux"
"go.opentelemetry.io/otel/propagation"
"github.com/SigNoz/signoz/pkg/query-service/constants"
"github.com/SigNoz/signoz/pkg/query-service/healthcheck"
"github.com/SigNoz/signoz/pkg/query-service/utils"
)
// Server runs auxiliary servers (opamp) alongside the signoz apiserver
// Server runs HTTP, Mux and a grpc server
type Server struct {
config signoz.Config
signoz *signoz.SigNoz
// public http router
httpConn net.Listener
httpServer *http.Server
httpHostPort string
opampServer *opamp.Server
unavailableChannel chan healthcheck.Status
}
// NewServer creates and initializes Server
@@ -64,20 +90,20 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
return nil, err
}
// Register the legacy query-service routes on the apiserver router. The
// apiserver owns the HTTP server and applies the middleware chain at serve
// time, so these routes get the same treatment as the apiserver routes.
r := signoz.APIServer.Router()
am := middleware.NewAuthZ(signoz.Instrumentation.Logger(), signoz.Modules.OrgGetter, signoz.Authz)
s := &Server{
config: config,
signoz: signoz,
httpHostPort: constants.HTTPHostPort,
unavailableChannel: make(chan healthcheck.Status),
}
apiHandler.RegisterRoutes(r, am)
apiHandler.RegisterLogsRoutes(r, am)
apiHandler.RegisterIntegrationRoutes(r, am)
apiHandler.RegisterQueryRangeV3Routes(r, am)
apiHandler.RegisterQueryRangeV4Routes(r, am)
apiHandler.RegisterMessagingQueuesRoutes(r, am)
apiHandler.RegisterThirdPartyApiRoutes(r, am)
apiHandler.RegisterTraceFunnelsRoutes(r, am)
httpServer, err := s.createPublicServer(apiHandler, signoz.Web)
if err != nil {
return nil, err
}
s.httpServer = httpServer
opAmpModel.Init(signoz.SQLStore, signoz.Instrumentation.Logger(), signoz.Modules.OrgGetter)
@@ -95,8 +121,6 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
return nil, err
}
s := &Server{}
s.opampServer = opamp.InitializeServer(
&opAmpModel.AllAgents,
agentConfMgr,
@@ -106,18 +130,146 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
return s, nil
}
// Start starts the opamp websocket server. The HTTP API server is started by
// the signoz registry.
func (s *Server) Start(ctx context.Context) error {
slog.Info("Starting OpAmp Websocket server", "addr", constants.OpAmpWsEndpoint)
if err := s.opampServer.Start(constants.OpAmpWsEndpoint); err != nil {
// HealthCheckStatus returns health check status channel a client can subscribe to
func (s Server) HealthCheckStatus() chan healthcheck.Status {
return s.unavailableChannel
}
func (s *Server) createPublicServer(api *APIHandler, web web.Web) (*http.Server, error) {
r := NewRouter()
r.Use(middleware.NewRecovery(s.signoz.Instrumentation.Logger()).Wrap)
r.Use(otelmux.Middleware(
"apiserver",
otelmux.WithMeterProvider(s.signoz.Instrumentation.MeterProvider()),
otelmux.WithTracerProvider(s.signoz.Instrumentation.TracerProvider()),
otelmux.WithPropagators(propagation.NewCompositeTextMapPropagator(propagation.Baggage{}, propagation.TraceContext{})),
otelmux.WithFilter(func(r *http.Request) bool {
return !slices.Contains([]string{"/api/v1/health"}, r.URL.Path)
}),
))
r.Use(middleware.NewIdentN(s.signoz.IdentNResolver, s.signoz.Sharder, s.signoz.Instrumentation.Logger()).Wrap)
r.Use(middleware.NewTimeout(s.signoz.Instrumentation.Logger(),
s.config.APIServer.Timeout.ExcludedRoutes,
s.config.APIServer.Timeout.Default,
s.config.APIServer.Timeout.Max,
).Wrap)
r.Use(middleware.NewResource(s.signoz.Instrumentation.Logger()).Wrap)
r.Use(middleware.NewAudit(s.signoz.Instrumentation.Logger(), s.config.APIServer.Logging.ExcludedRoutes, s.signoz.Auditor).Wrap)
r.Use(middleware.NewComment().Wrap)
am := middleware.NewAuthZ(s.signoz.Instrumentation.Logger(), s.signoz.Modules.OrgGetter, s.signoz.Authz)
api.RegisterRoutes(r, am)
api.RegisterLogsRoutes(r, am)
api.RegisterIntegrationRoutes(r, am)
api.RegisterQueryRangeV3Routes(r, am)
api.RegisterQueryRangeV4Routes(r, am)
api.RegisterMessagingQueuesRoutes(r, am)
api.RegisterThirdPartyApiRoutes(r, am)
api.RegisterTraceFunnelsRoutes(r, am)
err := s.signoz.APIServer.AddToRouter(r)
if err != nil {
return nil, err
}
c := cors.New(cors.Options{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "DELETE", "POST", "PUT", "PATCH", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "cache-control"},
})
handler := c.Handler(r)
handler = handlers.CompressHandler(handler)
err = web.AddToRouter(r)
if err != nil {
return nil, err
}
routePrefix := s.config.Global.ExternalPath()
if routePrefix != "" {
prefixed := http.StripPrefix(routePrefix, handler)
handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
switch req.URL.Path {
case "/api/v1/health", "/api/v2/healthz", "/api/v2/readyz", "/api/v2/livez":
r.ServeHTTP(w, req)
return
}
prefixed.ServeHTTP(w, req)
})
}
return &http.Server{
Handler: handler,
}, nil
}
// initListeners initialises listeners of the server
func (s *Server) initListeners() error {
// listen on public port
var err error
publicHostPort := s.httpHostPort
if publicHostPort == "" {
return fmt.Errorf("constants.HTTPHostPort is required")
}
s.httpConn, err = net.Listen("tcp", publicHostPort)
if err != nil {
return err
}
slog.Info(fmt.Sprintf("Query server started listening on %s...", s.httpHostPort))
return nil
}
// Start listening on http and private http port concurrently
func (s *Server) Start(ctx context.Context) error {
err := s.initListeners()
if err != nil {
return err
}
var httpPort int
if port, err := utils.GetPort(s.httpConn.Addr()); err == nil {
httpPort = port
}
go func() {
slog.Info("Starting HTTP server", "port", httpPort, "addr", s.httpHostPort)
switch err := s.httpServer.Serve(s.httpConn); err {
case nil, http.ErrServerClosed, cmux.ErrListenerClosed:
// normal exit, nothing to do
default:
slog.Error("Could not start HTTP server", errors.Attr(err))
}
s.unavailableChannel <- healthcheck.Unavailable
}()
go func() {
slog.Info("Starting OpAmp Websocket server", "addr", constants.OpAmpWsEndpoint)
err := s.opampServer.Start(constants.OpAmpWsEndpoint)
if err != nil {
slog.Error("opamp ws server failed to start", errors.Attr(err))
s.unavailableChannel <- healthcheck.Unavailable
}
}()
return nil
}
func (s *Server) Stop(ctx context.Context) error {
if s.httpServer != nil {
if err := s.httpServer.Shutdown(context.Background()); err != nil {
return err
}
}
s.opampServer.Stop()
return nil

View File

@@ -10,7 +10,11 @@ import (
"github.com/SigNoz/signoz/pkg/valuer"
)
const OpAmpWsEndpoint = "0.0.0.0:4320" // address for opamp websocket
const (
HTTPHostPort = "0.0.0.0:8080" // Address to serve http (query service)
PrivateHostPort = "0.0.0.0:8085" // Address to server internal services like alert manager
OpAmpWsEndpoint = "0.0.0.0:4320" // address for opamp websocket
)
const MaxAllowedPointsInTimeSeries = 300

View File

@@ -0,0 +1,12 @@
package healthcheck
const (
// Unavailable indicates the service is not able to handle requests
Unavailable Status = iota
// Ready indicates the service is ready to handle requests
Ready
// Broken indicates that the healthcheck itself is broken, not serving HTTP
Broken
)
type Status int

View File

@@ -156,7 +156,7 @@ func TestManager_TestNotification_SendUnmatched_PromRule(t *testing.T) {
triggeredTestAlerts := []map[*alertmanagertypes.PostableAlert][]string{}
// Variable to store promProvider for cleanup
var promProvider prometheus.Prometheus
var promProvider *prometheustest.Provider
// Create manager using test factory with hooks
mgr := NewTestManager(t, &TestManagerOptions{
@@ -181,29 +181,74 @@ func TestManager_TestNotification_SendUnmatched_PromRule(t *testing.T) {
TelemetryStoreHook: func(store telemetrystore.TelemetryStore) {
mockStore := store.(*telemetrystoretest.Provider)
// Grid the TestNotification eval computes over (see
// Timestamps on base_rule); nil args match any window.
// Set up Prometheus-specific mock data
// Fingerprint columns for Prometheus queries
fingerprintCols := []cmock.ColumnType{
{Name: "fingerprint", Type: "UInt64"},
{Name: "any(labels)", Type: "String"},
}
// Samples columns for Prometheus queries
samplesCols := []cmock.ColumnType{
{Name: "metric_name", Type: "String"},
{Name: "fingerprint", Type: "UInt64"},
{Name: "unix_milli", Type: "Int64"},
{Name: "value", Type: "Float64"},
{Name: "flags", Type: "UInt32"},
}
// Calculate query time range similar to Prometheus rule tests
// TestNotification uses time.Now().UTC() for evaluation
// We calculate the query window based on current time to match what the actual evaluation will use
evalTime := baseTime
evalWindowMs := int64(5 * 60 * 1000) // 5 minutes in ms
gridEnd := (evalTime.UnixMilli() / 60000) * 60000
gridStart := gridEnd - evalWindowMs
evalTimeMs := evalTime.UnixMilli()
queryStart := ((evalTimeMs-2*evalWindowMs)/60000)*60000 + 1 // truncate to minute + 1ms
queryEnd := (evalTimeMs / 60000) * 60000 // truncate to minute
tsList := make([]int64, 0, len(tc.Values))
vList := make([]float64, 0, len(tc.Values))
// Create fingerprint data
fingerprint := uint64(12345)
labelsJSON := `{"__name__":"test_metric"}`
fingerprintData := [][]any{
{fingerprint, labelsJSON},
}
fingerprintRows := cmock.NewRows(fingerprintCols, fingerprintData)
// Create samples data from test case values, calculating timestamps relative to baseTime
validSamplesData := make([][]any, 0)
for _, v := range tc.Values {
// Skip NaN and Inf values in the samples data
if math.IsNaN(v.Value) || math.IsInf(v.Value, 0) {
continue
}
tsList = append(tsList, baseTime.Add(v.Offset).UnixMilli())
vList = append(vList, v.Value)
// Calculate timestamp relative to baseTime
sampleTimestamp := baseTime.Add(v.Offset).UnixMilli()
validSamplesData = append(validSamplesData, []any{
"test_metric",
fingerprint,
sampleTimestamp,
v.Value,
uint32(0), // flags - 0 means normal value
})
}
grid := lastSampleGrid(tsList, vList, gridStart, gridEnd, 60_000, 300_000)
samplesRows := cmock.NewRows(samplesCols, validSamplesData)
mock := mockStore.Mock()
mock.ExpectQuery("SELECT gkey").
WithArgs("test_metric", nil, nil, "test_metric", nil, nil).
WillReturnRows(cmock.NewRows(gkeyCols, [][]any{{`[["__name__","test_metric"]]`, grid}}))
// Mock the fingerprint query (for Prometheus label matching)
mock.ExpectQuery("SELECT fingerprint, any").
WithArgs("test_metric").
WillReturnRows(fingerprintRows)
// Mock the samples query (for Prometheus metric data)
mock.ExpectQuery("SELECT metric_name, fingerprint, unix_milli").
WithArgs(
"test_metric",
"test_metric",
queryStart,
queryEnd,
).
WillReturnRows(samplesRows)
// Create Prometheus provider for this test
promProvider = prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{Timeout: 2 * time.Minute}, store)
@@ -237,6 +282,7 @@ func TestManager_TestNotification_SendUnmatched_PromRule(t *testing.T) {
assert.Empty(t, triggeredTestAlerts)
}
promProvider.Close()
})
}
}

View File

@@ -131,7 +131,7 @@ func NewTestManager(t *testing.T, testOpts *TestManagerOptions) *Manager {
meterStmtBuilder, err := meterstatementbuilder.NewFactory(metadataStore, flagger).New(ctx, providerSettings, cfg)
require.NoError(t, err)
bucketCache := querier.NewBucketCache(providerSettings, cache, 0, 0)
providerFactory := signozquerier.NewFactory(telemetryStore, prometheus, metadataStore, traceStmtBuilder, aiTraceStmtBuilder, logStmtBuilder, auditStmtBuilder, metricStmtBuilder, meterStmtBuilder, traceOperatorStmtBuilder, bucketCache, flagger)
providerFactory := signozquerier.NewFactory(telemetryStore, prometheus, nil, metadataStore, traceStmtBuilder, aiTraceStmtBuilder, logStmtBuilder, auditStmtBuilder, metricStmtBuilder, meterStmtBuilder, traceOperatorStmtBuilder, bucketCache, flagger)
mockQuerier, err := providerFactory.New(context.Background(), providerSettings, querier.Config{})
require.NoError(t, err)

View File

@@ -376,11 +376,17 @@ func (r *PromRule) String() string {
}
func (r *PromRule) RunAlertQuery(ctx context.Context, qs string, start, end time.Time, interval time.Duration) (promql.Matrix, error) {
res, err := r.prometheus.QueryRange(ctx, qs, start, end, interval)
q, err := r.prometheus.Engine().NewRangeQuery(ctx, r.prometheus.Storage(), nil, qs, start, end, interval)
if err != nil {
return nil, err
}
res := q.Exec(ctx)
if res.Err != nil {
return nil, res.Err
}
switch typ := res.Value.(type) {
case promql.Vector:
series := make([]promql.Series, 0, len(typ))

View File

@@ -9,8 +9,8 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
cmock "github.com/SigNoz/clickhouse-go-mock"
pql "github.com/prometheus/prometheus/promql"
cmock "github.com/SigNoz/clickhouse-go-mock"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
"github.com/SigNoz/signoz/pkg/prometheus"
@@ -740,31 +740,6 @@ func TestPromRuleEval(t *testing.T) {
}
}
var gkeyCols = []cmock.ColumnType{
{Name: "gkey", Type: "String"},
{Name: "grid", Type: "Array(Nullable(Float64))"},
}
// lastSampleGrid builds the grid a transpiled instant selector returns: per
// slot t, the latest sample in the left-open lookback window (t-lookback, t].
func lastSampleGrid(tsMs []int64, values []float64, startMs, endMs, stepMs, lookbackMs int64) []*float64 {
grid := make([]*float64, (endMs-startMs)/stepMs+1)
for i := range grid {
slot := startMs + int64(i)*stepMs
best := -1
for j, ts := range tsMs {
if ts > slot-lookbackMs && ts <= slot && (best == -1 || ts >= tsMs[best]) {
best = j
}
}
if best >= 0 {
v := values[best]
grid[i] = &v
}
}
return grid
}
func TestPromRuleUnitCombinations(t *testing.T) {
// fixed base time for deterministic tests
baseTime := time.Unix(1700000000, 0)
@@ -793,11 +768,26 @@ func TestPromRuleUnitCombinations(t *testing.T) {
},
}
// time_series_v4 cols of interest
fingerprintCols := []cmock.ColumnType{
{Name: "fingerprint", Type: "UInt64"},
{Name: "any(labels)", Type: "String"},
}
// samples_v4 columns
samplesCols := []cmock.ColumnType{
{Name: "metric_name", Type: "String"},
{Name: "fingerprint", Type: "UInt64"},
{Name: "unix_milli", Type: "Int64"},
{Name: "value", Type: "Float64"},
{Name: "flags", Type: "UInt32"},
}
// see Timestamps on base_rule
evalWindowMs := int64(5 * 60 * 1000) // 5 minutes in ms
evalTimeMs := evalTime.UnixMilli()
queryEnd := (evalTimeMs / 60000) * 60000 // truncate to minute
gridStart := queryEnd - evalWindowMs
queryStart := ((evalTimeMs-2*evalWindowMs)/60000)*60000 + 1 // truncate to minute + 1ms
queryEnd := (evalTimeMs / 60000) * 60000 // truncate to minute
cases := []struct {
targetUnit string
@@ -914,19 +904,43 @@ func TestPromRuleUnitCombinations(t *testing.T) {
for idx, c := range cases {
telemetryStore := telemetrystoretest.New(telemetrystore.Config{}, &queryMatcherAny{})
tsList := make([]int64, len(c.values))
vList := make([]float64, len(c.values))
for i, v := range c.values {
tsList[i] = v.timestamp.UnixMilli()
vList[i] = v.value
// single fingerprint with labels JSON
fingerprint := uint64(12345)
labelsJSON := `{"__name__":"test_metric"}`
fingerprintData := [][]any{
{fingerprint, labelsJSON},
}
grid := lastSampleGrid(tsList, vList, gridStart, queryEnd, 60_000, 300_000)
fingerprintRows := cmock.NewRows(fingerprintCols, fingerprintData)
// args: $1-$3=group-key join conditions, $4-$6=sample window
// create samples data from test case values
samplesData := make([][]any, len(c.values))
for i, v := range c.values {
samplesData[i] = []any{
"test_metric",
fingerprint,
v.timestamp.UnixMilli(),
v.value,
uint32(0), // flags - 0 means normal value, 1 means stale, we are not doing staleness tests
}
}
samplesRows := cmock.NewRows(samplesCols, samplesData)
// args: $1=metric_name (the __name__ matcher maps onto the column)
telemetryStore.Mock().
ExpectQuery("SELECT gkey").
WithArgs("test_metric", nil, nil, "test_metric", nil, nil).
WillReturnRows(cmock.NewRows(gkeyCols, [][]any{{`[["__name__","test_metric"]]`, grid}}))
ExpectQuery("SELECT fingerprint, any").
WithArgs("test_metric").
WillReturnRows(fingerprintRows)
// args: $1=metric_name IN (discovered names), $2=metric_name (subquery), $3=start, $4=end
telemetryStore.Mock().
ExpectQuery("SELECT metric_name, fingerprint, unix_milli").
WithArgs(
"test_metric",
"test_metric",
queryStart,
queryEnd,
).
WillReturnRows(samplesRows)
promProvider := prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{Timeout: 2 * time.Minute}, telemetryStore)
@@ -956,12 +970,14 @@ func TestPromRuleUnitCombinations(t *testing.T) {
rule, err := NewPromRule("69", valuer.GenerateUUID(), &postableRule, logger, promProvider, externalUrl)
if err != nil {
assert.NoError(t, err)
promProvider.Close()
continue
}
alertsFound, err := rule.Eval(context.Background(), evalTime)
if err != nil {
assert.NoError(t, err)
promProvider.Close()
continue
}
@@ -979,6 +995,7 @@ func TestPromRuleUnitCombinations(t *testing.T) {
assert.Equal(t, c.expectAlerts, foundCount, "case %d", idx)
}
promProvider.Close()
}
}
@@ -1010,6 +1027,12 @@ func TestPromRuleNoData(t *testing.T) {
},
}
// time_series_v4 cols of interest
fingerprintCols := []cmock.ColumnType{
{Name: "fingerprint", Type: "UInt64"},
{Name: "any(labels)", Type: "String"},
}
cases := []struct {
values []struct {
timestamp time.Time
@@ -1031,11 +1054,15 @@ func TestPromRuleNoData(t *testing.T) {
for idx, c := range cases {
telemetryStore := telemetrystoretest.New(telemetrystore.Config{}, &queryMatcherAny{})
// no data
fingerprintData := [][]any{}
fingerprintRows := cmock.NewRows(fingerprintCols, fingerprintData)
// no rows == no data
telemetryStore.Mock().
ExpectQuery("SELECT gkey").
WithArgs("test_metric", nil, nil, "test_metric", nil, nil).
WillReturnRows(cmock.NewRows(gkeyCols, [][]any{}))
ExpectQuery("SELECT fingerprint, any").
WithArgs("test_metric").
WillReturnRows(fingerprintRows)
promProvider := prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{Timeout: 2 * time.Minute}, telemetryStore)
@@ -1060,12 +1087,14 @@ func TestPromRuleNoData(t *testing.T) {
rule, err := NewPromRule("69", valuer.GenerateUUID(), &postableRule, logger, promProvider, externalUrl)
if err != nil {
assert.NoError(t, err)
promProvider.Close()
continue
}
alertsFound, err := rule.Eval(context.Background(), evalTime)
if err != nil {
assert.NoError(t, err)
promProvider.Close()
continue
}
@@ -1078,6 +1107,7 @@ func TestPromRuleNoData(t *testing.T) {
}
}
promProvider.Close()
}
}
@@ -1109,11 +1139,24 @@ func TestMultipleThresholdPromRule(t *testing.T) {
},
}
fingerprintCols := []cmock.ColumnType{
{Name: "fingerprint", Type: "UInt64"},
{Name: "any(labels)", Type: "String"},
}
samplesCols := []cmock.ColumnType{
{Name: "metric_name", Type: "String"},
{Name: "fingerprint", Type: "UInt64"},
{Name: "unix_milli", Type: "Int64"},
{Name: "value", Type: "Float64"},
{Name: "flags", Type: "UInt32"},
}
// see .Timestamps of base rule
evalWindowMs := int64(5 * 60 * 1000)
evalTimeMs := evalTime.UnixMilli()
queryStart := ((evalTimeMs-2*evalWindowMs)/60000)*60000 + 1
queryEnd := (evalTimeMs / 60000) * 60000
gridStart := queryEnd - evalWindowMs
cases := []struct {
targetUnit string
@@ -1207,19 +1250,39 @@ func TestMultipleThresholdPromRule(t *testing.T) {
for idx, c := range cases {
telemetryStore := telemetrystoretest.New(telemetrystore.Config{}, &queryMatcherAny{})
tsList := make([]int64, len(c.values))
vList := make([]float64, len(c.values))
for i, v := range c.values {
tsList[i] = v.timestamp.UnixMilli()
vList[i] = v.value
fingerprint := uint64(12345)
labelsJSON := `{"__name__":"test_metric"}`
fingerprintData := [][]any{
{fingerprint, labelsJSON},
}
grid := lastSampleGrid(tsList, vList, gridStart, queryEnd, 60_000, 300_000)
fingerprintRows := cmock.NewRows(fingerprintCols, fingerprintData)
samplesData := make([][]any, len(c.values))
for i, v := range c.values {
samplesData[i] = []any{
"test_metric",
fingerprint,
v.timestamp.UnixMilli(),
v.value,
uint32(0),
}
}
samplesRows := cmock.NewRows(samplesCols, samplesData)
// args: $1-$3=group-key join conditions, $4-$6=sample window
telemetryStore.Mock().
ExpectQuery("SELECT gkey").
WithArgs("test_metric", nil, nil, "test_metric", nil, nil).
WillReturnRows(cmock.NewRows(gkeyCols, [][]any{{`[["__name__","test_metric"]]`, grid}}))
ExpectQuery("SELECT fingerprint, any").
WithArgs("test_metric").
WillReturnRows(fingerprintRows)
telemetryStore.Mock().
ExpectQuery("SELECT metric_name, fingerprint, unix_milli").
WithArgs(
"test_metric",
"test_metric",
queryStart,
queryEnd,
).
WillReturnRows(samplesRows)
promProvider := prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{Timeout: 2 * time.Minute}, telemetryStore)
@@ -1256,12 +1319,14 @@ func TestMultipleThresholdPromRule(t *testing.T) {
rule, err := NewPromRule("69", valuer.GenerateUUID(), &postableRule, logger, promProvider, externalUrl)
if err != nil {
assert.NoError(t, err)
promProvider.Close()
continue
}
alertsFound, err := rule.Eval(context.Background(), evalTime)
if err != nil {
assert.NoError(t, err)
promProvider.Close()
continue
}
@@ -1279,6 +1344,7 @@ func TestMultipleThresholdPromRule(t *testing.T) {
assert.Equal(t, c.expectAlerts, foundCount, "case %d", idx)
}
promProvider.Close()
}
}
@@ -1312,6 +1378,27 @@ func TestPromRule_NoData(t *testing.T) {
},
}
// time_series_v4 cols of interest
fingerprintCols := []cmock.ColumnType{
{Name: "fingerprint", Type: "UInt64"},
{Name: "any(labels)", Type: "String"},
}
// samples_v4 columns
samplesCols := []cmock.ColumnType{
{Name: "metric_name", Type: "String"},
{Name: "fingerprint", Type: "UInt64"},
{Name: "unix_milli", Type: "Int64"},
{Name: "value", Type: "Float64"},
{Name: "flags", Type: "UInt32"},
}
// see Timestamps on base_rule
evalWindowMs := int64(5 * 60 * 1000) // 5 minutes in ms
evalTimeMs := evalTime.UnixMilli()
queryStart := ((evalTimeMs-2*evalWindowMs)/60000)*60000 + 1 // truncate to minute + 1ms
queryEnd := (evalTimeMs / 60000) * 60000 // truncate to minute
cases := []struct {
description string
alertOnAbsent bool
@@ -1343,11 +1430,18 @@ func TestPromRule_NoData(t *testing.T) {
telemetryStore := telemetrystoretest.New(telemetrystore.Config{}, &queryMatcherAny{})
// no rows == no data
// single fingerprint with labels JSON
fingerprint := uint64(12345)
labelsJSON := `{"__name__":"test_metric"}`
telemetryStore.Mock().
ExpectQuery("SELECT gkey").
WithArgs("test_metric", nil, nil, "test_metric", nil, nil).
WillReturnRows(cmock.NewRows(gkeyCols, [][]any{}))
ExpectQuery("SELECT fingerprint, any").
WithArgs("test_metric").
WillReturnRows(cmock.NewRows(fingerprintCols, [][]any{{fingerprint, labelsJSON}}))
telemetryStore.Mock().
ExpectQuery("SELECT metric_name, fingerprint, unix_milli").
WithArgs("test_metric", "test_metric", queryStart, queryEnd).
WillReturnRows(cmock.NewRows(samplesCols, [][]any{}))
promProvider := prometheustest.New(
context.Background(),
@@ -1356,6 +1450,7 @@ func TestPromRule_NoData(t *testing.T) {
telemetryStore,
)
defer func() {
_ = promProvider.Close()
}()
externalUrl := mustParseURL(t, "http://localhost:8080")
@@ -1415,6 +1510,19 @@ func TestPromRule_NoData_AbsentFor(t *testing.T) {
},
}
fingerprintCols := []cmock.ColumnType{
{Name: "fingerprint", Type: "UInt64"},
{Name: "any(labels)", Type: "String"},
}
samplesCols := []cmock.ColumnType{
{Name: "metric_name", Type: "String"},
{Name: "fingerprint", Type: "UInt64"},
{Name: "unix_milli", Type: "Int64"},
{Name: "value", Type: "Float64"},
{Name: "flags", Type: "UInt32"},
}
cases := []struct {
description string
absentFor uint64 // grace period in minutes
@@ -1448,30 +1556,43 @@ func TestPromRule_NoData_AbsentFor(t *testing.T) {
telemetryStore := telemetrystoretest.New(telemetrystore.Config{}, &queryMatcherAny{})
// Grid an eval at this time evaluates over (see Timestamps on
// base_rule).
calcGrid := func(evalTime time.Time) (int64, int64) {
gridEnd := (evalTime.UnixMilli() / 60000) * 60000
return gridEnd - evalWindow.Milliseconds(), gridEnd
fingerprint := uint64(12345)
labelsJSON := `{"__name__":"test_metric"}`
// Helper to calculate query time range for an eval time
calcQueryRange := func(evalTime time.Time) (int64, int64) {
evalTimeMs := evalTime.UnixMilli()
queryStart := ((evalTimeMs-2*evalWindow.Milliseconds())/60000)*60000 + 1
queryEnd := (evalTimeMs / 60000) * 60000
return queryStart, queryEnd
}
// First eval (t1) - with data: points in the past relative to t1
gridStart1, gridEnd1 := calcGrid(t1)
grid1 := lastSampleGrid(
[]int64{baseTime.UnixMilli(), baseTime.Add(1 * time.Minute).UnixMilli(), baseTime.Add(2 * time.Minute).UnixMilli()},
[]float64{100, 100, 100},
gridStart1, gridEnd1, 60_000, 300_000,
)
// First eval (t1) - with data
queryStart1, queryEnd1 := calcQueryRange(t1)
telemetryStore.Mock().
ExpectQuery("SELECT gkey").
WithArgs("test_metric", nil, nil, "test_metric", nil, nil).
WillReturnRows(cmock.NewRows(gkeyCols, [][]any{{`[["__name__","test_metric"]]`, grid1}}))
ExpectQuery("SELECT fingerprint, any").
WithArgs("test_metric").
WillReturnRows(cmock.NewRows(fingerprintCols, [][]any{{fingerprint, labelsJSON}}))
telemetryStore.Mock().
ExpectQuery("SELECT metric_name, fingerprint, unix_milli").
WithArgs("test_metric", "test_metric", queryStart1, queryEnd1).
WillReturnRows(cmock.NewRows(samplesCols, [][]any{
// Data points in the past relative to t1
{"test_metric", fingerprint, baseTime.UnixMilli(), 100.0, uint32(0)},
{"test_metric", fingerprint, baseTime.Add(1 * time.Minute).UnixMilli(), 100.0, uint32(0)},
{"test_metric", fingerprint, baseTime.Add(2 * time.Minute).UnixMilli(), 100.0, uint32(0)},
}))
// Second eval (t2) - no data
queryStart2, queryEnd2 := calcQueryRange(t2)
telemetryStore.Mock().
ExpectQuery("SELECT gkey").
WithArgs("test_metric", nil, nil, "test_metric", nil, nil).
WillReturnRows(cmock.NewRows(gkeyCols, [][]any{}))
ExpectQuery("SELECT fingerprint, any").
WithArgs("test_metric").
WillReturnRows(cmock.NewRows(fingerprintCols, [][]any{{fingerprint, labelsJSON}}))
telemetryStore.Mock().
ExpectQuery("SELECT metric_name, fingerprint, unix_milli").
WithArgs("test_metric", "test_metric", queryStart2, queryEnd2).
WillReturnRows(cmock.NewRows(samplesCols, [][]any{})) // empty - no data
promProvider := prometheustest.New(
context.Background(),
@@ -1480,6 +1601,7 @@ func TestPromRule_NoData_AbsentFor(t *testing.T) {
telemetryStore,
)
defer func() {
_ = promProvider.Close()
}()
externalUrl := mustParseURL(t, "http://localhost:8080")
@@ -1530,15 +1652,32 @@ func TestPromRuleEval_RequireMinPoints(t *testing.T) {
},
}
sampleTs := []int64{baseTime.UnixMilli(), baseTime.Add(time.Minute).UnixMilli(), baseTime.Add(2 * time.Minute).UnixMilli()}
sampleVs := []float64{100, 150, 250}
fingerprintCols := []cmock.ColumnType{
{Name: "fingerprint", Type: "UInt64"},
{Name: "any(labels)", Type: "String"},
}
fingerprint := uint64(12345)
fingerprintData := [][]any{{fingerprint, `{"__name__":"test_metric"}`}}
samplesCols := []cmock.ColumnType{
{Name: "metric_name", Type: "String"},
{Name: "fingerprint", Type: "UInt64"},
{Name: "unix_milli", Type: "Int64"},
{Name: "value", Type: "Float64"},
{Name: "flags", Type: "UInt32"},
}
samplesData := [][]any{
{"test_metric", fingerprint, baseTime.UnixMilli(), 100.0, 0},
{"test_metric", fingerprint, baseTime.Add(time.Minute).UnixMilli(), 150.0, 0},
{"test_metric", fingerprint, baseTime.Add(2 * time.Minute).UnixMilli(), 250.0, 0},
}
targetForAlert := 200.0
targetForNoAlert := 500.0
// see Timestamps on base_rule
evalTimeMs := evalTime.UnixMilli()
queryEnd := (evalTimeMs / 60000) * 60000 // truncate to minute
gridStart := queryEnd - evalWindow.Milliseconds()
queryStart := ((evalTimeMs-evalWindow.Milliseconds()-lookBackDelta.Milliseconds())/60000)*60000 + 1 // truncate to minute + 1ms
queryEnd := (evalTimeMs / 60000) * 60000 // truncate to minute
cases := []struct {
description string
@@ -1607,11 +1746,14 @@ func TestPromRuleEval_RequireMinPoints(t *testing.T) {
t.Run(c.description, func(t *testing.T) {
telemetryStore := telemetrystoretest.New(telemetrystore.Config{}, &queryMatcherAny{})
grid := lastSampleGrid(sampleTs, sampleVs, gridStart, queryEnd, 60_000, lookBackDelta.Milliseconds())
telemetryStore.Mock().
ExpectQuery("SELECT gkey").
WithArgs("test_metric", nil, nil, "test_metric", nil, nil).
WillReturnRows(cmock.NewRows(gkeyCols, [][]any{{`[["__name__","test_metric"]]`, grid}}))
ExpectQuery("SELECT fingerprint, any").
WithArgs("test_metric").
WillReturnRows(cmock.NewRows(fingerprintCols, fingerprintData))
telemetryStore.Mock().
ExpectQuery("SELECT metric_name, fingerprint, unix_milli").
WithArgs("test_metric", "test_metric", queryStart, queryEnd).
WillReturnRows(cmock.NewRows(samplesCols, samplesData))
promProvider := prometheustest.New(
context.Background(),
instrumentationtest.New().ToProviderSettings(),
@@ -1619,6 +1761,7 @@ func TestPromRuleEval_RequireMinPoints(t *testing.T) {
telemetryStore,
)
defer func() {
_ = promProvider.Close()
}()
externalUrl := mustParseURL(t, "http://localhost:8080")

View File

@@ -40,6 +40,7 @@ func prepareQuerierForMetrics(t *testing.T, telemetryStore telemetrystore.Teleme
telemetryStore,
metadataStore,
nil, // prometheus
nil, // promV2
nil, // traceStmtBuilder
nil, // aiTraceStmtBuilder
nil, // logStmtBuilder
@@ -75,6 +76,7 @@ func prepareQuerierForLogs(t *testing.T, telemetryStore telemetrystore.Telemetry
telemetryStore,
metadataStore,
nil, // prometheus
nil, // promV2
nil, // traceStmtBuilder
nil, // aiTraceStmtBuilder
logStmtBuilder,
@@ -111,6 +113,7 @@ func prepareQuerierForTraces(t *testing.T, telemetryStore telemetrystore.Telemet
telemetryStore,
metadataStore,
nil, // prometheus
nil, // promV2
traceStmtBuilder,
nil, // aiTraceStmtBuilder
nil, // logStmtBuilder

View File

@@ -10,14 +10,12 @@ import (
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/apiserver"
"github.com/SigNoz/signoz/pkg/apiserver/signozapiserver"
"github.com/SigNoz/signoz/pkg/auditor"
"github.com/SigNoz/signoz/pkg/authz"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/gateway"
"github.com/SigNoz/signoz/pkg/global"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/identn"
"github.com/SigNoz/signoz/pkg/instrumentation"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/modules/aiobservability"
@@ -44,11 +42,9 @@ import (
"github.com/SigNoz/signoz/pkg/prometheus"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/ruler"
"github.com/SigNoz/signoz/pkg/sharder"
"github.com/SigNoz/signoz/pkg/statsreporter"
"github.com/SigNoz/signoz/pkg/subscription"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/web"
"github.com/SigNoz/signoz/pkg/zeus"
"github.com/swaggest/jsonschema-go"
"github.com/swaggest/openapi-go"
@@ -105,11 +101,6 @@ func NewOpenAPI(ctx context.Context, instrumentation instrumentation.Instrumenta
struct{ ruler.Handler }{},
struct{ statsreporter.Handler }{},
struct{ savedview.Handler }{},
global.Config{},
struct{ identn.IdentNResolver }{},
struct{ sharder.Sharder }{},
struct{ auditor.Auditor }{},
struct{ web.Web }{},
struct{ quickfilter.Module }{},
struct{ quickfilter.Handler }{},
).New(ctx, instrumentation.ToProviderSettings(), apiserver.Config{})

View File

@@ -45,6 +45,7 @@ import (
"github.com/SigNoz/signoz/pkg/pprof/httppprof"
"github.com/SigNoz/signoz/pkg/pprof/nooppprof"
"github.com/SigNoz/signoz/pkg/prometheus"
"github.com/SigNoz/signoz/pkg/prometheus/clickhouseprometheus"
"github.com/SigNoz/signoz/pkg/prometheus/clickhouseprometheusv2"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/querier/signozquerier"
@@ -269,6 +270,7 @@ func NewTelemetryStoreProviderFactories() factory.NamedMap[factory.ProviderFacto
func NewPrometheusProviderFactories(telemetryStore telemetrystore.TelemetryStore) factory.NamedMap[factory.ProviderFactory[prometheus.Prometheus, prometheus.Config]] {
return factory.MustNewNamedMap(
clickhouseprometheus.NewFactory(telemetryStore),
clickhouseprometheusv2.NewFactory(telemetryStore),
)
}
@@ -311,13 +313,13 @@ func NewStatsReporterProviderFactories(aggregator statsreporter.Aggregator, orgG
)
}
func NewQuerierProviderFactories(telemetryStore telemetrystore.TelemetryStore, prometheus prometheus.Prometheus, metadataStore telemetrytypes.MetadataStore, traceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation], aiTraceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation], logStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation], auditStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation], metricStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation], meterStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation], traceOperatorStmtBuilder qbtypes.TraceOperatorStatementBuilder, bucketCache querier.BucketCache, flagger flagger.Flagger) factory.NamedMap[factory.ProviderFactory[querier.Querier, querier.Config]] {
func NewQuerierProviderFactories(telemetryStore telemetrystore.TelemetryStore, prometheus prometheus.Prometheus, promV2 prometheus.Prometheus, metadataStore telemetrytypes.MetadataStore, traceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation], aiTraceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation], logStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation], auditStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation], metricStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation], meterStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation], traceOperatorStmtBuilder qbtypes.TraceOperatorStatementBuilder, bucketCache querier.BucketCache, flagger flagger.Flagger) factory.NamedMap[factory.ProviderFactory[querier.Querier, querier.Config]] {
return factory.MustNewNamedMap(
signozquerier.NewFactory(telemetryStore, prometheus, metadataStore, traceStmtBuilder, aiTraceStmtBuilder, logStmtBuilder, auditStmtBuilder, metricStmtBuilder, meterStmtBuilder, traceOperatorStmtBuilder, bucketCache, flagger),
signozquerier.NewFactory(telemetryStore, prometheus, promV2, metadataStore, traceStmtBuilder, aiTraceStmtBuilder, logStmtBuilder, auditStmtBuilder, metricStmtBuilder, meterStmtBuilder, traceOperatorStmtBuilder, bucketCache, flagger),
)
}
func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.AuthZ, modules Modules, handlers Handlers, globalConfig global.Config, gatewayService gateway.Gateway, identNResolver identn.IdentNResolver, sharder sharder.Sharder, auditor auditor.Auditor, web web.Web) factory.NamedMap[factory.ProviderFactory[apiserver.APIServer, apiserver.Config]] {
func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.AuthZ, modules Modules, handlers Handlers, globalConfig global.Config, gatewayService gateway.Gateway) factory.NamedMap[factory.ProviderFactory[apiserver.APIServer, apiserver.Config]] {
return factory.MustNewNamedMap(
signozapiserver.NewFactory(
orgGetter,
@@ -359,11 +361,6 @@ func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.Au
handlers.RulerHandler,
handlers.StatsHandler,
handlers.SavedView,
globalConfig,
identNResolver,
sharder,
auditor,
web,
modules.QuickFilter,
handlers.QuickFilter,
),

View File

@@ -102,10 +102,6 @@ func TestNewProviderFactories(t *testing.T) {
Handlers{},
global.Config{},
nil,
nil,
nil,
nil,
nil,
)
})
}

View File

@@ -40,6 +40,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/tag/impltag"
"github.com/SigNoz/signoz/pkg/modules/user/impluser"
"github.com/SigNoz/signoz/pkg/prometheus"
"github.com/SigNoz/signoz/pkg/prometheus/clickhouseprometheusv2"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/queryparser"
"github.com/SigNoz/signoz/pkg/ruler"
@@ -308,6 +309,11 @@ func New(
retentionGetter := implretention.NewGetter(implretention.NewStore(sqlstore))
// promV2 is the clickhousev2 provider handed to the querier for shadow
// comparison and pinned serving (declared before the serving provider,
// whose variable shadows the package name below).
var promV2 prometheus.Prometheus
// Initialize prometheus from the available prometheus provider factories
prometheus, err := factory.NewProviderFromNamedMap(
ctx,
@@ -320,6 +326,23 @@ func New(
return nil, err
}
// With the default provider, also stand up the clickhousev2 provider for
// the querier: PromQL queries shadow-compare against it behind the
// use_prometheus_clickhouse_v2 flag (see pkg/querier/promql_shadow.go).
// It never serves by default. An explicit
// prometheus::provider: clickhousev2 makes v2 the serving provider
// outright, so there is nothing to compare against.
if config.Prometheus.Provider() == "clickhouse" {
v2Config := config.Prometheus
// The v2 engine only evaluates shadow and pinned queries; disable its
// active query tracker so two trackers never share a file.
v2Config.ActiveQueryTrackerConfig.Enabled = false
promV2, err = clickhouseprometheusv2.New(ctx, providerSettings, v2Config, telemetrystore)
if err != nil {
return nil, err
}
}
// Assemble the query stack (metadata store, statement builders, bucket cache) once,
// and reuse the single metadata store everywhere downstream.
telemetryMetadataStore, traceStmtBuilder, aiTraceStmtBuilder, logStmtBuilder, auditStmtBuilder, metricStmtBuilder, meterStmtBuilder, traceOperatorStmtBuilder, bucketCache, err := newQueryStack(ctx, providerSettings, config, telemetrystore, cache, flagger)
@@ -332,7 +355,7 @@ func New(
ctx,
providerSettings,
config.Querier,
NewQuerierProviderFactories(telemetrystore, prometheus, telemetryMetadataStore, traceStmtBuilder, aiTraceStmtBuilder, logStmtBuilder, auditStmtBuilder, metricStmtBuilder, meterStmtBuilder, traceOperatorStmtBuilder, bucketCache, flagger),
NewQuerierProviderFactories(telemetrystore, prometheus, promV2, telemetryMetadataStore, traceStmtBuilder, aiTraceStmtBuilder, logStmtBuilder, auditStmtBuilder, metricStmtBuilder, meterStmtBuilder, traceOperatorStmtBuilder, bucketCache, flagger),
config.Querier.Provider(),
)
if err != nil {
@@ -612,20 +635,13 @@ func New(
ctx,
providerSettings,
config.APIServer,
NewAPIServerProviderFactories(orgGetter, authz, modules, handlers, config.Global, gateway, identNResolver, sharder, auditor, web),
NewAPIServerProviderFactories(orgGetter, authz, modules, handlers, config.Global, gateway),
"signoz",
)
if err != nil {
return nil, err
}
// Register the API server with the registry so its lifecycle is managed
// alongside the other services and it shows up in the health endpoint.
err = registry.Add(ctx, factory.NewNamedService(factory.MustNewName("apiserver"), apiserverInstance))
if err != nil {
return nil, err
}
return &SigNoz{
Registry: registry,
Analytics: analytics,

View File

@@ -388,6 +388,14 @@ type QueryRangeRequest struct {
// NoCache is a flag to disable caching for the request.
NoCache bool `json:"noCache,omitempty"`
// PromQLProvider serves this request's PromQL queries via the named
// prometheus provider ("clickhousev2") instead of the default — the same
// data read through a different implementation. It is set from the
// X-SigNoz-PromQL-Provider header by the API handler, never from the
// body: a rollout-scoped comparison hook for integration tests and
// support should not become part of the public request schema.
PromQLProvider string `json:"-"`
FormatOptions *FormatOptions `json:"formatOptions,omitempty"`
}

View File

@@ -175,6 +175,7 @@ def make_query_request(
variables: dict | None = None,
no_cache: bool = True,
timeout: int = QUERY_TIMEOUT,
headers: dict | None = None,
) -> requests.Response:
if format_options is None:
format_options = {"formatTableResultForUI": False, "fillGaps": False}
@@ -194,7 +195,7 @@ def make_query_request(
return requests.post(
signoz.self.host_configs["8080"].get("/api/v5/query_range"),
timeout=timeout,
headers={"authorization": f"Bearer {token}"},
headers={"authorization": f"Bearer {token}", **(headers or {})},
json=payload,
)

View File

@@ -1,4 +1,4 @@
"""Seed data for the queriercommon keyless-semantics tests.
"""Seed data for the queriercommon keyless-semantics and explicit-context tests.
Three identities exist in every signal. GOLD and SILVER carry the test keys.
NONE carries no key at all. The tests assert which identities a filter
@@ -8,6 +8,7 @@ The attribute names are outside every semantic-convention family, so the
seeded data pins base behavior with any semconv overlay state.
"""
import json
from collections.abc import Callable, Generator
from datetime import UTC, datetime, timedelta
@@ -122,3 +123,103 @@ def keyless_series(insert_metrics: Callable[[list[Metrics]], None]) -> Generator
]
)
yield start, start + points * 60
EXPLICIT_PREFIX = "explicit-ctx"
# String attribute that identifies the row. It has one context only. Each
# assertion reads it back.
IDENTITY_KEY = "probe.id"
# Attribute with no column of the same name. It tests a key under the
# signal's own context that metadata does not know. On logs, the rows without
# the attribute have the value nested in the body JSON.
ATTRIBUTE_ONLY_KEY = "route.tag"
CONTESTED_VALUE = "checkout"
# Row identities. Each row shows where the contested value is:
# - COLUMN_ONLY: in the column (`name` on spans, `severity_text` on logs).
# - ATTRIBUTE_ONLY: in the string attribute with the same name.
# - BOTH: in the column and in the string attribute.
# - NEITHER: in none of them.
# - NUMBER_ATTRIBUTE: in a number attribute with the same name. Its data
# type is different from the column.
COLUMN_ONLY = f"{EXPLICIT_PREFIX}-column"
ATTRIBUTE_ONLY = f"{EXPLICIT_PREFIX}-attribute"
BOTH = f"{EXPLICIT_PREFIX}-both"
NEITHER = f"{EXPLICIT_PREFIX}-neither"
NUMBER_ATTRIBUTE = f"{EXPLICIT_PREFIX}-number"
NUMBER_VALUE = 42
# (identity, value in the column, value in the string attribute, value in
# the number attribute, resource service.name, attribute service.name,
# has route.tag, insert offset in seconds)
ROWS = [
(COLUMN_ONLY, True, False, False, "svc-a", None, True, 1),
(ATTRIBUTE_ONLY, False, True, False, "svc-b", "svc-a", False, 2),
(BOTH, True, True, False, "svc-a", "svc-a", True, 3),
(NEITHER, False, False, False, "svc-b", "svc-b", False, 4),
(NUMBER_ATTRIBUTE, False, False, True, "svc-b", None, False, 5),
]
# Logs only. The scope name is a declared path. A scope attribute also has
# the name `name`. A second scope attribute has a plain name.
SCOPE_NAME = "scope-a"
SCOPE_ATTRIBUTE_KEY = "env"
SCOPE_ATTRIBUTE_VALUE = "prod"
@pytest.fixture(name="ambiguous_rows", scope="function")
def ambiguous_rows(
insert_logs: Callable[[list[Logs]], None],
insert_traces: Callable[[list[Traces]], None],
) -> Generator[datetime]:
"""Inserts one span and one log for each identity. Every row has a
resource `service.name`. Some rows also have a span or log attribute
`service.name` with a different value. On logs, the rows without the
`route.tag` attribute have the value in the body JSON. Logs with the
column value have the scope name. Logs with the attribute value have the
scope attributes. Yields the base timestamp."""
now = datetime.now(tz=UTC).replace(microsecond=0) - timedelta(minutes=1)
insert_traces(
[
Traces(
timestamp=now - timedelta(seconds=offset),
duration=timedelta(milliseconds=10),
trace_id=TraceIdGenerator.trace_id(),
span_id=TraceIdGenerator.span_id(),
name=CONTESTED_VALUE if column else "other",
kind=TracesKind.SPAN_KIND_SERVER,
status_code=TracesStatusCode.STATUS_CODE_OK,
resources={"service.name": resource_service},
attributes={
IDENTITY_KEY: identity,
**({"name": CONTESTED_VALUE} if attribute else {}),
**({"name": NUMBER_VALUE} if number else {}),
**({"service.name": attribute_service} if attribute_service else {}),
**({ATTRIBUTE_ONLY_KEY: CONTESTED_VALUE} if tagged else {}),
},
)
for identity, column, attribute, number, resource_service, attribute_service, tagged, offset in ROWS
]
)
insert_logs(
[
Logs(
timestamp=now - timedelta(seconds=offset),
body=json.dumps({} if tagged else {"route": {"tag": CONTESTED_VALUE}}),
severity_text="ERROR" if column else "INFO",
scope_name=SCOPE_NAME if column else "",
scope_attributes={"name": CONTESTED_VALUE, SCOPE_ATTRIBUTE_KEY: SCOPE_ATTRIBUTE_VALUE} if attribute else {},
resources={"service.name": resource_service},
attributes={
IDENTITY_KEY: identity,
**({"severity_text": "ERROR"} if attribute else {}),
**({"severity_text": NUMBER_VALUE} if number else {}),
**({"service.name": attribute_service} if attribute_service else {}),
**({ATTRIBUTE_ONLY_KEY: CONTESTED_VALUE} if tagged else {}),
},
)
for identity, column, attribute, number, resource_service, attribute_service, tagged, offset in ROWS
]
)
yield now

View File

@@ -1,5 +1,5 @@
{
"note": "Divergences of the clickhousev2 provider from the upstream reference engine, enforced exactly by 01_upstream_corpus.py in both directions: a new divergence is a regression, and an entry that starts passing must be removed. The entries are the non-finite values the API filters and the Kahan-summation class.",
"note": "Divergences of the CURRENT promql serving path from the upstream reference engine, enforced exactly by 01_upstream_corpus.py in both directions. These document shipped defects, not test debt: the dominant class is the v1 remote-read fetch injecting a synthetic 'fingerprint' label into every series (pkg/prometheus/clickhouseprometheus/json.go), which breaks without() grouping and default vector matching. Entries must be REMOVED as the serving path is fixed or swapped. Second class, and the bulk of the entries below: promql_query.go drops NaN and +/-Inf from results, mirroring the builder path in consume.go, so every case whose expected output carries a non-finite value diverges on both legs. That class is a product decision rather than a defect, so it is not part of the burn-down.",
"divergences": {
"aggregators.test:630[base]": "expected output is entirely Inf; promql_query.go drops non-finite values, so no series is emitted",
"aggregators.test:630[instant-coarse]": "expected output is entirely Inf; promql_query.go drops non-finite values, so no series is emitted",
@@ -15,16 +15,8 @@
"aggregators.test:645[instant-coarse]": "expected output is entirely NaN; promql_query.go drops non-finite values, so no series is emitted",
"aggregators.test:648[base]": "expected output is entirely NaN; promql_query.go drops non-finite values, so no series is emitted",
"aggregators.test:648[instant-coarse]": "expected output is entirely NaN; promql_query.go drops non-finite values, so no series is emitted",
"aggregators.test:651[base]": "avg over near-max-float64 values: engine's incremental mean never forms the overflowing sum; avgForEach sums then divides, overflowing to +Inf",
"aggregators.test:651[instant-coarse]": "same as aggregators.test:651[base] on the coarse-step grid variant",
"aggregators.test:654[base]": "avg over near-min-float64 values: engine's incremental mean never forms the overflowing sum; avgForEach overflows to -Inf",
"aggregators.test:654[instant-coarse]": "same as aggregators.test:654[base] on the coarse-step grid variant",
"aggregators.test:661[base]": "expected output is entirely NaN; promql_query.go drops non-finite values, so no series is emitted",
"aggregators.test:661[instant-coarse]": "expected output is entirely NaN; promql_query.go drops non-finite values, so no series is emitted",
"aggregators.test:687[base]": "sum over {1e100, -1e100, small}: engine uses Kahan compensated summation; sumForEach's naive summation loses the small terms to cancellation and returns 0",
"aggregators.test:687[instant-coarse]": "same as aggregators.test:687[base] on the coarse-step grid variant",
"aggregators.test:695[base]": "avg over {1e100, -1e100, small}: same Kahan-vs-naive cancellation as aggregators.test:687, divided by count",
"aggregators.test:695[instant-coarse]": "same as aggregators.test:695[base] on the coarse-step grid variant",
"aggregators.test:698[base]": "expected output is entirely Inf; promql_query.go drops non-finite values, so no series is emitted",
"aggregators.test:698[instant-coarse]": "expected output is entirely Inf; promql_query.go drops non-finite values, so no series is emitted",
"aggregators.test:702[base]": "expected output is entirely Inf; promql_query.go drops non-finite values, so no series is emitted",
@@ -81,10 +73,6 @@
"aggregators.test:963[instant-coarse]": "expected output is entirely NaN; promql_query.go drops non-finite values, so no series is emitted",
"aggregators.test:966[base]": "expected output is entirely NaN; promql_query.go drops non-finite values, so no series is emitted",
"aggregators.test:966[instant-coarse]": "expected output is entirely NaN; promql_query.go drops non-finite values, so no series is emitted",
"functions.test:1084[instant-coarse]": "sum_over_time over a window containing ±1e100: the disjoint coarse-step form's arraySum slide is naive summation, cancelling to 0 (the base variant's W>64 shape falls back to the engine and is exact)",
"functions.test:1087[instant-coarse]": "avg_over_time, same window and cancellation as functions.test:1084[instant-coarse]",
"functions.test:1149[base]": "avg_over_time over ±2.258e220-magnitude samples: engine's Kahan-compensated incremental mean cancels exactly to 0; the bucketed form's naive slide summation leaves a ~1e202 residue",
"functions.test:1149[instant-coarse]": "same as functions.test:1149[base] through the disjoint coarse-step form",
"operators.test:533[base]": "expected output is entirely NaN; promql_query.go drops non-finite values, so no series is emitted",
"operators.test:533[instant-coarse]": "expected output is entirely NaN; promql_query.go drops non-finite values, so no series is emitted",
"operators.test:539[base]": "expected output is entirely NaN; promql_query.go drops non-finite values, so no series is emitted",

View File

@@ -0,0 +1,128 @@
{
"note": "Divergences of the clickhousev2 provider (pinned via X-SigNoz-PromQL-Provider) from the upstream reference engine, enforced exactly by 01_upstream_corpus.py in both directions. This ledger is the rollout scorecard for the provider swap: the default provider cannot be replaced by clickhousev2 while anything is listed here. Entries must carry the defect's cause and be REMOVED as the provider is fixed. Current class: the engine aggregates floats with Kahan compensated summation (sum, sum_over_time) and an overflow-free incremental mean (avg); ClickHouse's sumForEach/avgForEach/arraySum are naive, so extreme-magnitude corpus data (±1e100 cancellation, ±1.8e308 overflow) diverges on transpiled plans. Burn-down candidates: sumKahanForEach for the cancellation class; the overflow class needs an incremental-mean aggregate ClickHouse does not have. Second class, and the bulk of the entries below: promql_query.go drops NaN and +/-Inf from results, mirroring the builder path in consume.go, so every case whose expected output carries a non-finite value diverges on both legs. That class is a product decision rather than a defect, so it is not part of the burn-down.",
"divergences": {
"aggregators.test:630[base]": "expected output is entirely Inf; promql_query.go drops non-finite values, so no series is emitted",
"aggregators.test:630[instant-coarse]": "expected output is entirely Inf; promql_query.go drops non-finite values, so no series is emitted",
"aggregators.test:633[base]": "expected output is entirely Inf; promql_query.go drops non-finite values, so no series is emitted",
"aggregators.test:633[instant-coarse]": "expected output is entirely Inf; promql_query.go drops non-finite values, so no series is emitted",
"aggregators.test:636[base]": "expected output is entirely NaN; promql_query.go drops non-finite values, so no series is emitted",
"aggregators.test:636[instant-coarse]": "expected output is entirely NaN; promql_query.go drops non-finite values, so no series is emitted",
"aggregators.test:639[base]": "expected output is entirely -Inf; promql_query.go drops non-finite values, so no series is emitted",
"aggregators.test:639[instant-coarse]": "expected output is entirely -Inf; promql_query.go drops non-finite values, so no series is emitted",
"aggregators.test:642[base]": "expected output is entirely -Inf; promql_query.go drops non-finite values, so no series is emitted",
"aggregators.test:642[instant-coarse]": "expected output is entirely -Inf; promql_query.go drops non-finite values, so no series is emitted",
"aggregators.test:645[base]": "expected output is entirely NaN; promql_query.go drops non-finite values, so no series is emitted",
"aggregators.test:645[instant-coarse]": "expected output is entirely NaN; promql_query.go drops non-finite values, so no series is emitted",
"aggregators.test:648[base]": "expected output is entirely NaN; promql_query.go drops non-finite values, so no series is emitted",
"aggregators.test:648[instant-coarse]": "expected output is entirely NaN; promql_query.go drops non-finite values, so no series is emitted",
"aggregators.test:651[base]": "avg over near-max-float64 values: engine's incremental mean never forms the overflowing sum; avgForEach sums then divides, overflowing to +Inf",
"aggregators.test:651[instant-coarse]": "same as aggregators.test:651[base] on the coarse-step grid variant",
"aggregators.test:654[base]": "avg over near-min-float64 values: engine's incremental mean never forms the overflowing sum; avgForEach overflows to -Inf",
"aggregators.test:654[instant-coarse]": "same as aggregators.test:654[base] on the coarse-step grid variant",
"aggregators.test:661[base]": "expected output is entirely NaN; promql_query.go drops non-finite values, so no series is emitted",
"aggregators.test:661[instant-coarse]": "expected output is entirely NaN; promql_query.go drops non-finite values, so no series is emitted",
"aggregators.test:687[base]": "sum over {1e100, -1e100, small}: engine uses Kahan compensated summation; sumForEach's naive summation loses the small terms to cancellation and returns 0",
"aggregators.test:687[instant-coarse]": "same as aggregators.test:687[base] on the coarse-step grid variant",
"aggregators.test:695[base]": "avg over {1e100, -1e100, small}: same Kahan-vs-naive cancellation as aggregators.test:687, divided by count",
"aggregators.test:695[instant-coarse]": "same as aggregators.test:695[base] on the coarse-step grid variant",
"aggregators.test:698[base]": "expected output is entirely Inf; promql_query.go drops non-finite values, so no series is emitted",
"aggregators.test:698[instant-coarse]": "expected output is entirely Inf; promql_query.go drops non-finite values, so no series is emitted",
"aggregators.test:702[base]": "expected output is entirely Inf; promql_query.go drops non-finite values, so no series is emitted",
"aggregators.test:702[instant-coarse]": "expected output is entirely Inf; promql_query.go drops non-finite values, so no series is emitted",
"aggregators.test:706[base]": "expected output is entirely -Inf; promql_query.go drops non-finite values, so no series is emitted",
"aggregators.test:706[instant-coarse]": "expected output is entirely -Inf; promql_query.go drops non-finite values, so no series is emitted",
"aggregators.test:710[base]": "expected output is entirely -Inf; promql_query.go drops non-finite values, so no series is emitted",
"aggregators.test:710[instant-coarse]": "expected output is entirely -Inf; promql_query.go drops non-finite values, so no series is emitted",
"aggregators.test:714[base]": "expected output is entirely NaN; promql_query.go drops non-finite values, so no series is emitted",
"aggregators.test:714[instant-coarse]": "expected output is entirely NaN; promql_query.go drops non-finite values, so no series is emitted",
"aggregators.test:717[base]": "expected output is entirely NaN; promql_query.go drops non-finite values, so no series is emitted",
"aggregators.test:717[instant-coarse]": "expected output is entirely NaN; promql_query.go drops non-finite values, so no series is emitted",
"aggregators.test:720[base]": "expected output is entirely NaN; promql_query.go drops non-finite values, so no series is emitted",
"aggregators.test:720[instant-coarse]": "expected output is entirely NaN; promql_query.go drops non-finite values, so no series is emitted",
"aggregators.test:724[base]": "expected output is entirely NaN; promql_query.go drops non-finite values, so no series is emitted",
"aggregators.test:724[instant-coarse]": "expected output is entirely NaN; promql_query.go drops non-finite values, so no series is emitted",
"aggregators.test:862[base]": "expected output is entirely NaN; promql_query.go drops non-finite values, so no series is emitted",
"aggregators.test:862[instant-coarse]": "expected output is entirely NaN; promql_query.go drops non-finite values, so no series is emitted",
"aggregators.test:865[base]": "expected output is entirely NaN; promql_query.go drops non-finite values, so no series is emitted",
"aggregators.test:865[instant-coarse]": "expected output is entirely NaN; promql_query.go drops non-finite values, so no series is emitted",
"aggregators.test:868[base]": "1 of 3 expected points are NaN; promql_query.go drops non-finite values, so those timestamps are missing",
"aggregators.test:868[instant-coarse]": "1 of 3 expected points are NaN; promql_query.go drops non-finite values, so those timestamps are missing",
"aggregators.test:873[base]": "1 of 3 expected points are NaN; promql_query.go drops non-finite values, so those timestamps are missing",
"aggregators.test:873[instant-coarse]": "1 of 3 expected points are NaN; promql_query.go drops non-finite values, so those timestamps are missing",
"aggregators.test:885[base]": "expected output is entirely NaN; promql_query.go drops non-finite values, so no series is emitted",
"aggregators.test:885[instant-coarse]": "expected output is entirely NaN; promql_query.go drops non-finite values, so no series is emitted",
"aggregators.test:888[base]": "expected output is entirely NaN; promql_query.go drops non-finite values, so no series is emitted",
"aggregators.test:888[instant-coarse]": "expected output is entirely NaN; promql_query.go drops non-finite values, so no series is emitted",
"aggregators.test:891[base]": "1 of 3 expected points are NaN; promql_query.go drops non-finite values, so those timestamps are missing",
"aggregators.test:891[instant-coarse]": "1 of 3 expected points are NaN; promql_query.go drops non-finite values, so those timestamps are missing",
"aggregators.test:896[base]": "1 of 3 expected points are NaN; promql_query.go drops non-finite values, so those timestamps are missing",
"aggregators.test:896[instant-coarse]": "1 of 3 expected points are NaN; promql_query.go drops non-finite values, so those timestamps are missing",
"aggregators.test:906[base]": "expected output is entirely NaN; promql_query.go drops non-finite values, so no series is emitted",
"aggregators.test:906[instant-coarse]": "expected output is entirely NaN; promql_query.go drops non-finite values, so no series is emitted",
"aggregators.test:909[base]": "expected output is entirely NaN; promql_query.go drops non-finite values, so no series is emitted",
"aggregators.test:909[instant-coarse]": "expected output is entirely NaN; promql_query.go drops non-finite values, so no series is emitted",
"aggregators.test:919[base]": "expected output is entirely NaN; promql_query.go drops non-finite values, so no series is emitted",
"aggregators.test:919[instant-coarse]": "expected output is entirely NaN; promql_query.go drops non-finite values, so no series is emitted",
"aggregators.test:922[base]": "expected output is entirely NaN; promql_query.go drops non-finite values, so no series is emitted",
"aggregators.test:922[instant-coarse]": "expected output is entirely NaN; promql_query.go drops non-finite values, so no series is emitted",
"aggregators.test:925[base]": "1 of 3 expected points are NaN; promql_query.go drops non-finite values, so those timestamps are missing",
"aggregators.test:925[instant-coarse]": "1 of 3 expected points are NaN; promql_query.go drops non-finite values, so those timestamps are missing",
"aggregators.test:930[base]": "1 of 3 expected points are NaN; promql_query.go drops non-finite values, so those timestamps are missing",
"aggregators.test:930[instant-coarse]": "1 of 3 expected points are NaN; promql_query.go drops non-finite values, so those timestamps are missing",
"aggregators.test:942[base]": "expected output is entirely NaN; promql_query.go drops non-finite values, so no series is emitted",
"aggregators.test:942[instant-coarse]": "expected output is entirely NaN; promql_query.go drops non-finite values, so no series is emitted",
"aggregators.test:945[base]": "expected output is entirely NaN; promql_query.go drops non-finite values, so no series is emitted",
"aggregators.test:945[instant-coarse]": "expected output is entirely NaN; promql_query.go drops non-finite values, so no series is emitted",
"aggregators.test:948[base]": "1 of 3 expected points are NaN; promql_query.go drops non-finite values, so those timestamps are missing",
"aggregators.test:948[instant-coarse]": "1 of 3 expected points are NaN; promql_query.go drops non-finite values, so those timestamps are missing",
"aggregators.test:953[base]": "1 of 3 expected points are NaN; promql_query.go drops non-finite values, so those timestamps are missing",
"aggregators.test:953[instant-coarse]": "1 of 3 expected points are NaN; promql_query.go drops non-finite values, so those timestamps are missing",
"aggregators.test:963[base]": "expected output is entirely NaN; promql_query.go drops non-finite values, so no series is emitted",
"aggregators.test:963[instant-coarse]": "expected output is entirely NaN; promql_query.go drops non-finite values, so no series is emitted",
"aggregators.test:966[base]": "expected output is entirely NaN; promql_query.go drops non-finite values, so no series is emitted",
"aggregators.test:966[instant-coarse]": "expected output is entirely NaN; promql_query.go drops non-finite values, so no series is emitted",
"functions.test:1084[instant-coarse]": "sum_over_time over a window containing ±1e100: the disjoint coarse-step form's arraySum slide is naive summation, cancelling to 0 (the base variant's W>64 shape falls back to the engine and is exact)",
"functions.test:1087[instant-coarse]": "avg_over_time, same window and cancellation as functions.test:1084[instant-coarse]",
"functions.test:1149[base]": "avg_over_time over ±2.258e220-magnitude samples: engine's Kahan-compensated incremental mean cancels exactly to 0; the bucketed form's naive slide summation leaves a ~1e202 residue",
"functions.test:1149[instant-coarse]": "same as functions.test:1149[base] through the disjoint coarse-step form",
"operators.test:533[base]": "expected output is entirely NaN; promql_query.go drops non-finite values, so no series is emitted",
"operators.test:533[instant-coarse]": "expected output is entirely NaN; promql_query.go drops non-finite values, so no series is emitted",
"operators.test:539[base]": "expected output is entirely NaN; promql_query.go drops non-finite values, so no series is emitted",
"trig_functions.test:13[base]": "1 of 3 expected points are NaN; promql_query.go drops non-finite values, so those timestamps are missing",
"trig_functions.test:13[instant-coarse]": "1 of 3 expected points are NaN; promql_query.go drops non-finite values, so those timestamps are missing",
"trig_functions.test:18[base]": "1 of 3 expected points are NaN; promql_query.go drops non-finite values, so those timestamps are missing",
"trig_functions.test:18[instant-coarse]": "1 of 3 expected points are NaN; promql_query.go drops non-finite values, so those timestamps are missing",
"trig_functions.test:23[base]": "2 of 3 expected points are NaN; promql_query.go drops non-finite values, so those timestamps are missing",
"trig_functions.test:23[instant-coarse]": "2 of 3 expected points are NaN; promql_query.go drops non-finite values, so those timestamps are missing",
"trig_functions.test:28[base]": "2 of 3 expected points are NaN; promql_query.go drops non-finite values, so those timestamps are missing",
"trig_functions.test:28[instant-coarse]": "2 of 3 expected points are NaN; promql_query.go drops non-finite values, so those timestamps are missing",
"trig_functions.test:33[base]": "1 of 3 expected points are NaN; promql_query.go drops non-finite values, so those timestamps are missing",
"trig_functions.test:33[instant-coarse]": "1 of 3 expected points are NaN; promql_query.go drops non-finite values, so those timestamps are missing",
"trig_functions.test:38[base]": "1 of 3 expected points are NaN; promql_query.go drops non-finite values, so those timestamps are missing",
"trig_functions.test:38[instant-coarse]": "1 of 3 expected points are NaN; promql_query.go drops non-finite values, so those timestamps are missing",
"trig_functions.test:43[base]": "1 of 3 expected points are NaN; promql_query.go drops non-finite values, so those timestamps are missing",
"trig_functions.test:43[instant-coarse]": "1 of 3 expected points are NaN; promql_query.go drops non-finite values, so those timestamps are missing",
"trig_functions.test:48[base]": "1 of 3 expected points are NaN; promql_query.go drops non-finite values, so those timestamps are missing",
"trig_functions.test:48[instant-coarse]": "1 of 3 expected points are NaN; promql_query.go drops non-finite values, so those timestamps are missing",
"trig_functions.test:53[base]": "1 of 3 expected points are NaN; promql_query.go drops non-finite values, so those timestamps are missing",
"trig_functions.test:53[instant-coarse]": "1 of 3 expected points are NaN; promql_query.go drops non-finite values, so those timestamps are missing",
"trig_functions.test:58[base]": "1 of 3 expected points are NaN; promql_query.go drops non-finite values, so those timestamps are missing",
"trig_functions.test:58[instant-coarse]": "1 of 3 expected points are NaN; promql_query.go drops non-finite values, so those timestamps are missing",
"trig_functions.test:63[base]": "2 of 3 expected points are NaN; promql_query.go drops non-finite values, so those timestamps are missing",
"trig_functions.test:63[instant-coarse]": "2 of 3 expected points are NaN; promql_query.go drops non-finite values, so those timestamps are missing",
"trig_functions.test:68[base]": "1 of 3 expected points are NaN; promql_query.go drops non-finite values, so those timestamps are missing",
"trig_functions.test:68[instant-coarse]": "1 of 3 expected points are NaN; promql_query.go drops non-finite values, so those timestamps are missing",
"trig_functions.test:73[base]": "1 of 3 expected points are NaN; promql_query.go drops non-finite values, so those timestamps are missing",
"trig_functions.test:73[instant-coarse]": "1 of 3 expected points are NaN; promql_query.go drops non-finite values, so those timestamps are missing",
"trig_functions.test:78[base]": "1 of 3 expected points are NaN; promql_query.go drops non-finite values, so those timestamps are missing",
"trig_functions.test:78[instant-coarse]": "1 of 3 expected points are NaN; promql_query.go drops non-finite values, so those timestamps are missing",
"trig_functions.test:83[base]": "1 of 3 expected points are NaN; promql_query.go drops non-finite values, so those timestamps are missing",
"trig_functions.test:83[instant-coarse]": "1 of 3 expected points are NaN; promql_query.go drops non-finite values, so those timestamps are missing",
"trig_functions.test:88[base]": "1 of 3 expected points are NaN; promql_query.go drops non-finite values, so those timestamps are missing",
"trig_functions.test:88[instant-coarse]": "1 of 3 expected points are NaN; promql_query.go drops non-finite values, so those timestamps are missing",
"trig_functions.test:8[base]": "1 of 3 expected points are NaN; promql_query.go drops non-finite values, so those timestamps are missing",
"trig_functions.test:8[instant-coarse]": "1 of 3 expected points are NaN; promql_query.go drops non-finite values, so those timestamps are missing",
"trig_functions.test:93[base]": "1 of 3 expected points are NaN; promql_query.go drops non-finite values, so those timestamps are missing",
"trig_functions.test:93[instant-coarse]": "1 of 3 expected points are NaN; promql_query.go drops non-finite values, so those timestamps are missing"
}
}

View File

@@ -12,9 +12,11 @@ from fixtures.promqltestcorpus import ingest_promqltest_corpus
# The same frozen corpus the promqlconformance package replays through
# /api/v5/query_range, here replayed against the /prometheus/api/v1 endpoints
# — the path nothing else exercises. Range cases go to query_range, which
# serves transpiled statements when the shape allows. Instant cases go to
# /query with a real `time` parameter, so they need no grid encoding.
# with clickhousev2 as the serving provider (see conftest.py) — the two paths
# nothing else exercises. Range cases go to query_range, where a
# RangeExecutor provider serves transpiled statements when the shape allows.
# Instant cases go to /query with a real `time` parameter, so they need no
# grid encoding.
#
# Prometheus API sample values are strings, "NaN"/"+Inf"/"-Inf" included.
SPECIALS = {"NaN": math.nan, "Inf": math.inf, "+Inf": math.inf, "-Inf": -math.inf}
@@ -36,7 +38,8 @@ def test_prometheus_api_corpus(
# range because the v5 API cannot run true instants. This API can:
# the [base] form of the same eval goes through /query below, and the
# transpiled coarse-step serving the encoding exercises is covered
# (and its known divergences ledgered) by promqlconformance.
# (and its known divergences ledgered) by promqlconformance's
# clickhousev2 leg.
if case["variant"] == "instant-coarse":
continue

View File

@@ -0,0 +1,37 @@
import pytest
from testcontainers.core.container import Network
from fixtures import types
from fixtures.signoz import create_signoz
@pytest.fixture(name="signoz", scope="package")
def signoz_promapi_v2(
network: Network,
migrator: types.Operation, # pylint: disable=unused-argument
zeus: types.TestContainerDocker,
gateway: types.TestContainerDocker,
sqlstore: types.TestContainerSQL,
clickhouse: types.TestContainerClickhouse,
request: pytest.FixtureRequest,
pytestconfig: pytest.Config,
) -> types.SigNoz:
"""
SigNoz with clickhousev2 as the serving prometheus provider. The corpus
replays against the /prometheus/api/v1 endpoints, so this package covers
the two paths nothing else serves: v2 as the provider (range queries
transpile when the shape allows), and the Prometheus HTTP API contract.
"""
return create_signoz(
network=network,
zeus=zeus,
gateway=gateway,
sqlstore=sqlstore,
clickhouse=clickhouse,
request=request,
pytestconfig=pytestconfig,
cache_key="signoz-promapi-v2",
env_overrides={
"SIGNOZ_PROMETHEUS_PROVIDER": "clickhousev2",
},
)

View File

@@ -18,11 +18,27 @@ TESTDATA_DIR = os.path.join(os.path.dirname(__file__), "..", "..", "testdata")
# itself is the thing being changed — the one situation where comparing two
# live paths against each other is blind.
# The ledger is enforced exactly in both directions: a new divergence is a
# regression, and a known divergence that starts passing must be removed. Its
# entries are the frozen defects of the serving path (the non-finite API
# filtering, and the clickhousev2 Kahan-summation class).
LEDGER_FILE = os.path.join(TESTDATA_DIR, "promqltestcorpus", "known_divergences.json")
# One ledger per leg, enforced exactly in both directions. The default leg's
# ledger is empty and pinned there; the clickhousev2 ledger is the rollout
# scorecard — the provider swap is measured by burning it down to empty.
LEDGER_FILES = {
"default": os.path.join(TESTDATA_DIR, "promqltestcorpus", "known_divergences.json"),
"clickhousev2": os.path.join(TESTDATA_DIR, "promqltestcorpus", "known_divergences_v2.json"),
}
# Every case replays on both legs, each asserted against the same frozen
# expectations and its own ledger — deliberately never against each other: both
# legs can sit within one rounding quantum of the expected value yet differ from
# each other by up to two quanta when a true value straddles a rounding boundary,
# so a leg-vs-leg equality check would reintroduce exactly the boundary noise the
# quantum tolerance absorbs. A case failing on one leg while passing on the other
# already localizes the defect to that provider; the printed DIVERGED lines for
# both legs are the side-by-side triage view. The clickhousev2 header is
# flag-gated (see conftest.py).
LEGS: list[tuple[str, dict | None]] = [
("default", None),
("clickhousev2", {"X-SigNoz-PromQL-Provider": "clickhousev2"}),
]
SPECIALS = {"NaN": math.nan, "Inf": math.inf, "-Inf": -math.inf}
@@ -36,7 +52,7 @@ def test_upstream_promqltest_corpus(
corpus, bases = ingest_promqltest_corpus(insert_metrics)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
failures: list[str] = []
failures: dict[str, list[str]] = {leg: [] for leg, _ in LEGS}
for case in corpus["cases"]:
base = bases[case["dataset"]]
start_ms = base + case["start_ms"]
@@ -55,96 +71,104 @@ def test_upstream_promqltest_corpus(
}
case_id = f"{case['source']}[{case['variant']}]"
response = make_query_request(signoz, token, req_start_ms, end_ms, [query])
if response.status_code != HTTPStatus.OK:
failures.append(f"{case_id}: HTTP {response.status_code} for {case['expr']!r}: {response.text[:200]}")
continue
for leg, headers in LEGS:
response = make_query_request(signoz, token, req_start_ms, end_ms, [query], headers=headers)
if response.status_code != HTTPStatus.OK:
failures[leg].append(f"{case_id}: HTTP {response.status_code} for {case['expr']!r}: {response.text[:200]}")
continue
# A response carrying several series with identical visible labels
# is itself a defect signal (e.g. a hidden grouping label stripped
# on the way out) and must not be silently collapsed into one entry.
actual: dict[tuple, dict[int, float]] = {}
duplicates: list[tuple] = []
# Empty results serialize with null aggregations/series/values fields.
for series in get_all_series(response.json(), "A") or []:
lbls = {l["key"]["name"]: str(l["value"]) for l in series.get("labels") or []}
points = {int(v["timestamp"]): SPECIALS[v["value"]] if isinstance(v["value"], str) else float(v["value"]) for v in series.get("values") or []}
key = tuple(sorted(lbls.items()))
if key in actual:
duplicates.append(key)
actual[key] = points
if duplicates:
failures.append(f"{case_id}: response carries multiple series with identical labels for {case['expr']!r}: {[dict(d) for d in duplicates[:3]]}")
continue
# A response carrying several series with identical visible labels
# is itself a defect signal (e.g. a hidden grouping label stripped
# on the way out) and must not be silently collapsed into one entry.
actual: dict[tuple, dict[int, float]] = {}
duplicates: list[tuple] = []
# Empty results serialize with null aggregations/series/values fields.
for series in get_all_series(response.json(), "A") or []:
lbls = {l["key"]["name"]: str(l["value"]) for l in series.get("labels") or []}
points = {int(v["timestamp"]): SPECIALS[v["value"]] if isinstance(v["value"], str) else float(v["value"]) for v in series.get("values") or []}
key = tuple(sorted(lbls.items()))
if key in actual:
duplicates.append(key)
actual[key] = points
if duplicates:
failures[leg].append(f"{case_id}: response carries multiple series with identical labels for {case['expr']!r}: {[dict(d) for d in duplicates[:3]]}")
continue
if case["instant"]:
# Keep only the instant point; the extra grid step is a request
# encoding byproduct, not part of the assertion.
actual = {lset: {ts: v for ts, v in pts.items() if ts == end_ms} for lset, pts in actual.items()}
actual = {lset: pts for lset, pts in actual.items() if pts}
expected: dict[tuple, dict[int, float]] = {}
for res in case["expected"]:
points = {base + off_ms: SPECIALS[v] if isinstance(v, str) else float(v) for off_ms, v in res["points"]}
expected[tuple(sorted(res["labels"].items()))] = points
if case["instant"]:
# Keep only the instant point; the extra grid step is a request
# encoding byproduct, not part of the assertion.
actual = {lset: {ts: v for ts, v in pts.items() if ts == end_ms} for lset, pts in actual.items()}
actual = {lset: pts for lset, pts in actual.items() if pts}
expected: dict[tuple, dict[int, float]] = {}
for res in case["expected"]:
points = {base + off_ms: SPECIALS[v] if isinstance(v, str) else float(v) for off_ms, v in res["points"]}
expected[tuple(sorted(res["labels"].items()))] = points
if set(actual) != set(expected):
missing = set(expected) - set(actual)
extra = set(actual) - set(expected)
failures.append(f"{case_id}: series mismatch for {case['expr']!r} (missing={sorted(missing)[:3]} extra={sorted(extra)[:3]}) actual={[(dict(k), {t - base: v for t, v in pts.items()}) for k, pts in actual.items()]}")
continue
if set(actual) != set(expected):
missing = set(expected) - set(actual)
extra = set(actual) - set(expected)
failures[leg].append(f"{case_id}: series mismatch for {case['expr']!r} (missing={sorted(missing)[:3]} extra={sorted(extra)[:3]}) actual={[(dict(k), {t - base: v for t, v in pts.items()}) for k, pts in actual.items()]}")
continue
mismatch = None
for lset, exp_points in expected.items():
act_points = actual[lset]
if set(act_points) != set(exp_points):
mismatch = f"{case_id}: timestamp mismatch for {case['expr']!r} series {dict(lset)} (expected {len(exp_points)} points, got {len(act_points)})"
break
for ts, exp_v in exp_points.items():
act_v = act_points[ts]
if math.isnan(act_v) or math.isnan(exp_v):
close = math.isnan(act_v) and math.isnan(exp_v)
elif math.isinf(act_v) or math.isinf(exp_v):
close = act_v == exp_v
elif act_v == exp_v:
close = True
else:
# Both sides carry the API's rounding (>=1: three decimal places; <1:
# three significant digits). A true value sitting exactly on a rounding
# boundary can round either way when the two computations differ at ULP
# level (float aggregation order over series is storage-iteration
# dependent), so allow one rounding quantum.
scale = max(abs(act_v), abs(exp_v))
if scale >= 1:
# Values too large to round pass through unrounded; give those an
# ULP-class relative grace on top of the rounding quantum.
quantum = max(1e-3, scale * 1e-9)
mismatch = None
for lset, exp_points in expected.items():
act_points = actual[lset]
if set(act_points) != set(exp_points):
mismatch = f"{case_id}: timestamp mismatch for {case['expr']!r} series {dict(lset)} (expected {len(exp_points)} points, got {len(act_points)})"
break
for ts, exp_v in exp_points.items():
act_v = act_points[ts]
if math.isnan(act_v) or math.isnan(exp_v):
close = math.isnan(act_v) and math.isnan(exp_v)
elif math.isinf(act_v) or math.isinf(exp_v):
close = act_v == exp_v
elif act_v == exp_v:
close = True
else:
quantum = 10 ** (math.floor(math.log10(scale)) - 2)
close = abs(act_v - exp_v) <= quantum + 1e-12
if not close:
mismatch = f"{case_id}: value mismatch for {case['expr']!r} series {dict(lset)} at {ts}: expected {exp_v}, got {act_v}"
# Both sides carry the API's rounding (>=1: three decimal places; <1:
# three significant digits). A true value sitting exactly on a rounding
# boundary can round either way when the two computations differ at ULP
# level (float aggregation order over series is storage-iteration
# dependent), so allow one rounding quantum.
scale = max(abs(act_v), abs(exp_v))
if scale >= 1:
# Values too large to round pass through unrounded; give those an
# ULP-class relative grace on top of the rounding quantum.
quantum = max(1e-3, scale * 1e-9)
else:
quantum = 10 ** (math.floor(math.log10(scale)) - 2)
close = abs(act_v - exp_v) <= quantum + 1e-12
if not close:
mismatch = f"{case_id}: value mismatch for {case['expr']!r} series {dict(lset)} at {ts}: expected {exp_v}, got {act_v}"
break
if mismatch:
break
if mismatch:
break
if mismatch:
failures.append(mismatch)
failures[leg].append(mismatch)
for f_line in failures:
print("DIVERGED", f_line)
known: dict[str, str] = {}
if os.path.exists(LEDGER_FILE):
with open(LEDGER_FILE, encoding="utf-8") as f:
known = json.load(f)["divergences"]
failed_ids = {f_line.split(": ", 1)[0] for f_line in failures}
unexpected = [f_line for f_line in failures if f_line.split(": ", 1)[0] not in known]
now_passing = sorted(set(known) - failed_ids)
for leg, _ in LEGS:
for f_line in failures[leg]:
print("DIVERGED", f"[{leg}]", f_line)
# Known divergences are defects of that leg's serving path, frozen with
# reasons. Each set is enforced exactly in both directions: a NEW
# divergence is a regression, and a known divergence that starts passing
# must be removed from the file. Problems across both legs are collected
# before asserting so one leg's failure never hides the other's.
problems: list[str] = []
if unexpected:
problems.append(f"{len(unexpected)} corpus cases diverged beyond the known set:\n" + "\n".join(unexpected[:25]))
if now_passing:
problems.append(f"{len(now_passing)} known divergences now pass — remove them from {os.path.basename(LEDGER_FILE)}: {now_passing[:25]}")
for leg, _ in LEGS:
known: dict[str, str] = {}
if os.path.exists(LEDGER_FILES[leg]):
with open(LEDGER_FILES[leg], encoding="utf-8") as f:
known = json.load(f)["divergences"]
failed_ids = {f_line.split(": ", 1)[0] for f_line in failures[leg]}
unexpected = [f_line for f_line in failures[leg] if f_line.split(": ", 1)[0] not in known]
now_passing = sorted(set(known) - failed_ids)
if unexpected:
problems.append(f"[{leg}] {len(unexpected)} corpus cases diverged beyond the known set:\n" + "\n".join(unexpected[:25]))
if now_passing:
problems.append(f"[{leg}] {len(now_passing)} known divergences now pass — remove them from {os.path.basename(LEDGER_FILES[leg])}: {now_passing[:25]}")
assert not problems, "\n\n".join(problems)

View File

@@ -10,6 +10,11 @@ from fixtures.querier import get_all_series, make_query_request
MINUTE_MS = 60_000
LEGS: list[tuple[str, dict | None]] = [
("default", None),
("clickhousev2", {"X-SigNoz-PromQL-Provider": "clickhousev2"}),
]
def test_promql_subquery_without_step_evaluates(
signoz: types.SigNoz,
@@ -40,15 +45,16 @@ def test_promql_subquery_without_step_evaluates(
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
query = {"type": "promql", "spec": {"name": "A", "query": f"max_over_time({metric}[5m:])"}}
response = make_query_request(signoz, token, start_ms, end_ms, [query])
assert response.status_code == HTTPStatus.OK, response.text[:300]
series = get_all_series(response.json(), "A")
assert series, "the subquery must return the inserted series"
values = {point["value"] for entry in series for point in entry.get("values") or []}
assert values == {42.0}, sorted(values)[:5]
for leg, headers in LEGS:
query = {"type": "promql", "spec": {"name": "A", "query": f"max_over_time({metric}[5m:])"}}
response = make_query_request(signoz, token, start_ms, end_ms, [query], headers=headers)
assert response.status_code == HTTPStatus.OK, f"{leg}: {response.text[:300]}"
series = get_all_series(response.json(), "A")
assert series, f"{leg}: the subquery must return the inserted series"
values = {point["value"] for entry in series for point in entry.get("values") or []}
assert values == {42.0}, f"{leg}: {sorted(values)[:5]}"
# A plain follow-up query proves the process survived the subquery.
# A plain follow-up query proves the process survived the subquery legs.
response = make_query_request(
signoz,
token,

View File

@@ -0,0 +1,39 @@
import pytest
from testcontainers.core.container import Network
from fixtures import types
from fixtures.signoz import create_signoz
@pytest.fixture(name="signoz", scope="package")
def signoz_promql_conformance(
network: Network,
migrator: types.Operation, # pylint: disable=unused-argument
zeus: types.TestContainerDocker,
gateway: types.TestContainerDocker,
sqlstore: types.TestContainerSQL,
clickhouse: types.TestContainerClickhouse,
request: pytest.FixtureRequest,
pytestconfig: pytest.Config,
) -> types.SigNoz:
"""
Package-scoped SigNoz with use_prometheus_clickhouse_v2 on, so the corpus
can replay every case twice: once against the default provider and once
pinned to the clickhousev2 provider via the X-SigNoz-PromQL-Provider
header (which the flag gates). Each leg is asserted against the same
frozen expectations — see 01_upstream_corpus.py for why the legs are
never asserted against each other.
"""
return create_signoz(
network=network,
zeus=zeus,
gateway=gateway,
sqlstore=sqlstore,
clickhouse=clickhouse,
request=request,
pytestconfig=pytestconfig,
cache_key="signoz-promql-conformance",
env_overrides={
"SIGNOZ_FLAGGER_CONFIG_BOOLEAN_USE__PROMETHEUS__CLICKHOUSE__V2": True,
},
)

View File

@@ -0,0 +1,381 @@
from collections.abc import Callable
from datetime import datetime, timedelta
from http import HTTPStatus
import pytest
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.querier import (
RequestType,
assert_scalar_value,
build_aggregation,
build_group_by_field,
build_order_by,
build_raw_query,
build_scalar_query,
get_all_warnings,
get_column_data_from_response,
get_scalar_table_data,
make_query_request,
)
from fixtures.queriercommon import (
ATTRIBUTE_ONLY,
BOTH,
COLUMN_ONLY,
EXPLICIT_PREFIX,
IDENTITY_KEY,
NEITHER,
NUMBER_ATTRIBUTE,
)
# One name can exist in more than one place. `name` is a span column and a
# span attribute. `severity_text` is a log column and a log attribute.
# `service.name` is a resource attribute and a span or log attribute.
#
# Rules for a filter:
# - A key with an explicit context reads that context only.
# - A bare key that is a column and an attribute reads both. The query
# returns an ambiguity warning.
# - A bare key that is a resource attribute and an attribute reads the
# resource attribute. The query returns an ambiguity warning.
# - An `attribute.` key returns the warning when the attribute has two data
# types.
# - A string operand matches a number attribute through a text cast.
# - A key under the signal's own context (`span.`, `log.`) that exists only
# as an attribute reads the attribute. On logs it also reads the body JSON
# path.
FILTER_MATRIX = [
pytest.param("{contested} = '{value}'", {COLUMN_ONLY, ATTRIBUTE_ONLY, BOTH}, True, id="bare_column_and_attribute"),
pytest.param("{own}.{contested} = '{value}'", {COLUMN_ONLY, BOTH}, False, id="own_context_column_only"),
pytest.param("attribute.{contested} = '{value}'", {ATTRIBUTE_ONLY, BOTH}, True, id="attribute_context_warns_about_two_types"),
pytest.param("{contested} != '{value}'", {NEITHER, NUMBER_ATTRIBUTE}, True, id="bare_negative_excludes_every_carrier"),
pytest.param("{contested} EXISTS", {COLUMN_ONLY, ATTRIBUTE_ONLY, BOTH, NEITHER, NUMBER_ATTRIBUTE}, True, id="bare_exists_is_the_column"),
pytest.param("{contested} NOT EXISTS", set(), True, id="bare_not_exists_is_never"),
pytest.param("attribute.{contested} EXISTS", {ATTRIBUTE_ONLY, BOTH, NUMBER_ATTRIBUTE}, True, id="attribute_exists_spans_both_types"),
pytest.param("attribute.{contested} NOT EXISTS", {COLUMN_ONLY, NEITHER}, True, id="attribute_not_exists"),
pytest.param("{contested} = '42'", {NUMBER_ATTRIBUTE}, True, id="bare_string_operand_reaches_the_number_attribute"),
pytest.param("attribute.{contested}:string = '{value}'", {ATTRIBUTE_ONLY, BOTH}, False, id="type_suffix_selects_the_string_attribute"),
pytest.param("attribute.{contested}:float64 = 42", {NUMBER_ATTRIBUTE}, False, id="type_suffix_selects_the_number_attribute"),
pytest.param("service.name = 'svc-a'", {COLUMN_ONLY, BOTH}, True, id="bare_resource_wins_with_warning"),
pytest.param("service.name != 'svc-a'", {ATTRIBUTE_ONLY, NEITHER, NUMBER_ATTRIBUTE}, True, id="bare_resource_negative"),
pytest.param("resource.service.name = 'svc-a'", {COLUMN_ONLY, BOTH}, False, id="resource_context_no_warning"),
pytest.param("resource.service.name != 'svc-a'", {ATTRIBUTE_ONLY, NEITHER, NUMBER_ATTRIBUTE}, False, id="resource_context_negative"),
pytest.param("attribute.service.name = 'svc-a'", {ATTRIBUTE_ONLY, BOTH}, False, id="attribute_context_no_warning"),
pytest.param(
"{own}.route.tag = 'checkout'",
{"traces": {COLUMN_ONLY, BOTH}, "logs": {COLUMN_ONLY, ATTRIBUTE_ONLY, BOTH, NEITHER, NUMBER_ATTRIBUTE}},
False,
id="own_context_miss_corrects_to_attribute_and_on_logs_to_body",
),
pytest.param("route.tag = 'checkout'", {COLUMN_ONLY, BOTH}, False, id="bare_attribute_only_key"),
]
SIGNALS = [
pytest.param("traces", "span", "name", "checkout", "other", id="traces"),
pytest.param("logs", "log", "severity_text", "ERROR", "INFO", id="logs"),
]
@pytest.mark.parametrize("expression_template,expected,expects_ambiguity_warning", FILTER_MATRIX)
@pytest.mark.parametrize("signal,own_context,contested,value,other_value", SIGNALS)
def test_filter_resolution(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
ambiguous_rows: datetime,
signal: str,
own_context: str,
contested: str,
value: str,
other_value: str, # pylint: disable=unused-argument
expression_template: str,
expected: set[str] | dict[str, set[str]],
expects_ambiguity_warning: bool,
) -> None:
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
expression = expression_template.format(own=own_context, contested=contested, value=value)
response = make_query_request(
signoz,
token,
start_ms=int((ambiguous_rows - timedelta(minutes=2)).timestamp() * 1000),
end_ms=int((ambiguous_rows + timedelta(minutes=1)).timestamp() * 1000),
request_type=RequestType.RAW,
queries=[
build_raw_query(
"A",
signal,
limit=100,
filter_expression=expression,
order=[build_order_by("timestamp", "asc")],
select_fields=[{"name": IDENTITY_KEY}],
)
],
)
assert response.status_code == HTTPStatus.OK, response.text
matched = {row for row in get_column_data_from_response(response.json(), IDENTITY_KEY) if row.startswith(EXPLICIT_PREFIX)}
assert matched == (expected[signal] if isinstance(expected, dict) else expected), expression
warnings = [w["message"] for w in get_all_warnings(response.json())]
assert any("ambiguous" in w for w in warnings) == expects_ambiguity_warning, warnings
# Rules for a group by:
# - A bare key that is a column and an attribute groups by the column only.
# - A key with an explicit context groups by that context only.
GROUP_BY_MATRIX = [
pytest.param(None, {"{value}": 2, "{other}": 3}, id="bare_groups_by_the_column"),
pytest.param("own", {"{value}": 2, "{other}": 3}, id="own_context_groups_by_the_column"),
pytest.param("attribute", {"{value}": 2}, id="attribute_context_groups_by_the_attribute"),
]
@pytest.mark.parametrize("context,expected_template", GROUP_BY_MATRIX)
@pytest.mark.parametrize("signal,own_context,contested,value,other_value", SIGNALS)
def test_group_by_resolution(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
ambiguous_rows: datetime,
signal: str,
own_context: str,
contested: str,
value: str,
other_value: str,
context: str | None,
expected_template: dict[str, int],
) -> None:
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
field_context = own_context if context == "own" else context
response = make_query_request(
signoz,
token,
start_ms=int((ambiguous_rows - timedelta(minutes=2)).timestamp() * 1000),
end_ms=int((ambiguous_rows + timedelta(minutes=1)).timestamp() * 1000),
request_type=RequestType.SCALAR,
queries=[
build_scalar_query(
"A",
signal,
[build_aggregation("count()", "rows")],
group_by=[build_group_by_field(contested, "string", field_context) if field_context else {"name": contested}],
filter_expression=f"{IDENTITY_KEY} LIKE '{EXPLICIT_PREFIX}%'",
)
],
)
assert response.status_code == HTTPStatus.OK, response.text
expected = {key.format(value=value, other=other_value): count for key, count in expected_template.items()}
groups = {row[0]: row[1] for row in get_scalar_table_data(response.json()) if row[0] in expected}
assert groups == expected, get_scalar_table_data(response.json())
# Rule for a raw select of a bare key that is a resource attribute and an
# attribute: each row shows the resource value. This is also true for a row
# where the attribute has a different value.
@pytest.mark.parametrize("signal", ["traces", "logs"])
def test_select_of_ambiguous_name(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
ambiguous_rows: datetime,
signal: str,
) -> None:
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_query_request(
signoz,
token,
start_ms=int((ambiguous_rows - timedelta(minutes=2)).timestamp() * 1000),
end_ms=int((ambiguous_rows + timedelta(minutes=1)).timestamp() * 1000),
request_type=RequestType.RAW,
queries=[
build_raw_query(
"A",
signal,
limit=100,
filter_expression=f"{IDENTITY_KEY} LIKE '{EXPLICIT_PREFIX}%'",
order=[build_order_by("timestamp", "asc")],
select_fields=[{"name": IDENTITY_KEY}, {"name": "service.name"}],
)
],
)
assert response.status_code == HTTPStatus.OK, response.text
rows = response.json()["data"]["data"]["results"][0]["rows"] or []
by_identity = {row["data"][IDENTITY_KEY]: row["data"]["service.name"] for row in rows if row["data"].get(IDENTITY_KEY, "").startswith(EXPLICIT_PREFIX)}
assert by_identity == {
COLUMN_ONLY: "svc-a",
ATTRIBUTE_ONLY: "svc-b",
BOTH: "svc-a",
NEITHER: "svc-b",
NUMBER_ATTRIBUTE: "svc-b",
}
# Rules for an order by, descending, with the timestamp descending as the
# second key:
# - A bare key or a key under the signal's own context sorts by the column
# only.
# - An `attribute.` key sorts by the attribute on traces. The number
# attribute sorts as text. Rows without the attribute come last.
# - An `attribute.` key sorts by the column on logs.
BY_COLUMN = [ATTRIBUTE_ONLY, NEITHER, NUMBER_ATTRIBUTE, COLUMN_ONLY, BOTH]
ORDER_BY_MATRIX = [
pytest.param(None, {"traces": BY_COLUMN, "logs": BY_COLUMN}, id="bare_orders_by_the_column"),
pytest.param("own", {"traces": BY_COLUMN, "logs": BY_COLUMN}, id="own_context_orders_by_the_column"),
pytest.param(
"attribute",
{"traces": [ATTRIBUTE_ONLY, BOTH, NUMBER_ATTRIBUTE, COLUMN_ONLY, NEITHER], "logs": BY_COLUMN},
id="attribute_context_orders_by_the_attribute_on_traces_only",
),
]
@pytest.mark.parametrize("context,expected", ORDER_BY_MATRIX)
@pytest.mark.parametrize("signal,own_context,contested,value,other_value", SIGNALS)
def test_order_by_resolution(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
ambiguous_rows: datetime,
signal: str,
own_context: str,
contested: str,
value: str, # pylint: disable=unused-argument
other_value: str, # pylint: disable=unused-argument
context: str | None,
expected: dict[str, list[str]],
) -> None:
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
prefix = f"{own_context}." if context == "own" else f"{context}." if context else ""
response = make_query_request(
signoz,
token,
start_ms=int((ambiguous_rows - timedelta(minutes=2)).timestamp() * 1000),
end_ms=int((ambiguous_rows + timedelta(minutes=1)).timestamp() * 1000),
request_type=RequestType.RAW,
queries=[
build_raw_query(
"A",
signal,
limit=100,
filter_expression=f"{IDENTITY_KEY} LIKE '{EXPLICIT_PREFIX}%'",
order=[build_order_by(f"{prefix}{contested}", "desc"), build_order_by("timestamp", "desc")],
select_fields=[{"name": IDENTITY_KEY}],
)
],
)
assert response.status_code == HTTPStatus.OK, response.text
ordered = [row for row in get_column_data_from_response(response.json(), IDENTITY_KEY) if row.startswith(EXPLICIT_PREFIX)]
assert ordered == expected[signal]
# Rules for an aggregation argument:
# - A bare key counts the values of the column only.
# - An `attribute.` key counts the attribute in both data types. The number
# attribute adds one distinct value.
AGGREGATION_MATRIX = [
pytest.param(None, 2, id="bare_counts_the_column"),
pytest.param("own", 2, id="own_context_counts_the_column"),
pytest.param("attribute", 2, id="attribute_context_counts_both_attribute_types"),
]
@pytest.mark.parametrize("context,expected", AGGREGATION_MATRIX)
@pytest.mark.parametrize("signal,own_context,contested,value,other_value", SIGNALS)
def test_aggregation_argument_resolution(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
ambiguous_rows: datetime,
signal: str,
own_context: str,
contested: str,
value: str, # pylint: disable=unused-argument
other_value: str, # pylint: disable=unused-argument
context: str | None,
expected: int,
) -> None:
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
prefix = f"{own_context}." if context == "own" else f"{context}." if context else ""
response = make_query_request(
signoz,
token,
start_ms=int((ambiguous_rows - timedelta(minutes=2)).timestamp() * 1000),
end_ms=int((ambiguous_rows + timedelta(minutes=1)).timestamp() * 1000),
request_type=RequestType.SCALAR,
queries=[
build_scalar_query(
"A",
signal,
[build_aggregation(f"count_distinct({prefix}{contested})", "distinct")],
filter_expression=f"{IDENTITY_KEY} LIKE '{EXPLICIT_PREFIX}%'",
)
],
)
assert response.status_code == HTTPStatus.OK, response.text
assert_scalar_value(response, "A", expected)
# Rules for logs only:
# - A `body.` key reads the body JSON path. It does not read the attribute
# with the same name.
# - A `log.` key reads the attribute and the body JSON path together. This
# is also true when metadata reports the attribute.
# - A `scope.` key resolves through metadata only. When metadata does not
# report the key, the query fails with "key not found". This is also true
# for the declared path `scope.name` and for rows that have the scope
# data.
LOGS_ONLY_MATRIX = [
pytest.param("body.route.tag = 'checkout'", {ATTRIBUTE_ONLY, NEITHER, NUMBER_ATTRIBUTE}, id="body_context_reads_the_body_json"),
pytest.param("log.route.tag = 'checkout'", {COLUMN_ONLY, ATTRIBUTE_ONLY, BOTH, NEITHER, NUMBER_ATTRIBUTE}, id="log_context_reads_attribute_and_body"),
pytest.param("scope.name = 'scope-a'", "key `name` not found", id="scope_name_needs_metadata"),
pytest.param("scope.env = 'prod'", "key `env` not found", id="scope_attribute_needs_metadata"),
pytest.param("scope.env EXISTS", "key `env` not found", id="scope_attribute_exists_needs_metadata"),
]
@pytest.mark.parametrize("expression,expected", LOGS_ONLY_MATRIX)
def test_logs_body_and_scope_contexts(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
ambiguous_rows: datetime,
expression: str,
expected: set[str] | str,
) -> None:
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_query_request(
signoz,
token,
start_ms=int((ambiguous_rows - timedelta(minutes=2)).timestamp() * 1000),
end_ms=int((ambiguous_rows + timedelta(minutes=1)).timestamp() * 1000),
request_type=RequestType.RAW,
queries=[
build_raw_query(
"A",
"logs",
limit=100,
filter_expression=expression,
order=[build_order_by("timestamp", "asc")],
select_fields=[{"name": IDENTITY_KEY}],
)
],
)
if isinstance(expected, str):
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
assert expected in response.text, response.text
return
assert response.status_code == HTTPStatus.OK, response.text
matched = {row for row in get_column_data_from_response(response.json(), IDENTITY_KEY) if row.startswith(EXPLICIT_PREFIX)}
assert matched == expected, expression