Skip to content

Commit 3c9b99f

Browse files
committed
feat: introduce provider for signoz
Signed-off-by: Pranav <pranav10121@gmail.com>
1 parent 3a27fd1 commit 3c9b99f

3 files changed

Lines changed: 471 additions & 0 deletions

File tree

pkg/metrics/providers/factory.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ type Factory struct{}
2525

2626
func (factory Factory) Provider(metricInterval string, provider flaggerv1.MetricTemplateProvider, credentials map[string][]byte, config *rest.Config) (Interface, error) {
2727
switch provider.Type {
28+
case "signoz":
29+
return NewSignozProvider(provider, credentials)
2830
case "prometheus":
2931
return NewPrometheusProvider(provider, credentials)
3032
case "datadog":

pkg/metrics/providers/signoz.go

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
/*
2+
Copyright 2025 The Flux authors
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package providers
18+
19+
import (
20+
"context"
21+
"crypto/tls"
22+
"encoding/json"
23+
"fmt"
24+
"io"
25+
"math"
26+
"net/http"
27+
"net/url"
28+
"path"
29+
"strconv"
30+
"strings"
31+
"time"
32+
33+
flaggerv1 "github.com/fluxcd/flagger/pkg/apis/flagger/v1beta1"
34+
)
35+
36+
// SignozAPIPath is the default query range endpoint appended to the base address.
37+
var SignozAPIPath = "/api/v5/query_range"
38+
39+
// SignozProvider executes SigNoz Query Range API requests
40+
type SignozProvider struct {
41+
timeout time.Duration
42+
url url.URL
43+
headers http.Header
44+
client *http.Client
45+
queryPath string
46+
}
47+
48+
// signozResponse models a flexible subset of SigNoz responses
49+
// It supports both single value and range values under common fields.
50+
type signozResponse struct {
51+
Data struct {
52+
Result []struct {
53+
// Prometheus-like compatibility
54+
Value []interface{} `json:"value"`
55+
Values [][]interface{} `json:"values"`
56+
57+
// SigNoz series array
58+
Series []struct {
59+
Values [][]interface{} `json:"values"`
60+
} `json:"series"`
61+
} `json:"result"`
62+
} `json:"data"`
63+
}
64+
65+
// NewSignozProvider takes a provider spec and the credentials map,
66+
// validates the address, extracts the API key from the provided Secret,
67+
// and returns a client ready to execute requests against the SigNoz API.
68+
func NewSignozProvider(provider flaggerv1.MetricTemplateProvider, _ map[string][]byte) (*SignozProvider, error) {
69+
signozURL, err := url.Parse(provider.Address)
70+
if provider.Address == "" || err != nil {
71+
return nil, fmt.Errorf("%s address %s is not a valid URL", provider.Type, provider.Address)
72+
}
73+
74+
sp := SignozProvider{
75+
timeout: 5 * time.Second,
76+
url: *signozURL,
77+
headers: provider.Headers,
78+
client: http.DefaultClient,
79+
queryPath: SignozAPIPath,
80+
}
81+
82+
if provider.InsecureSkipVerify {
83+
t := http.DefaultTransport.(*http.Transport).Clone()
84+
t.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
85+
sp.client = &http.Client{Transport: t}
86+
}
87+
88+
return &sp, nil
89+
}
90+
91+
// RunQuery posts the provided JSON payload to SigNoz query_range and
92+
// returns a single float64 value derived from the response.
93+
//
94+
// Expectations:
95+
// - The input `query` is a valid JSON document per SigNoz Query Range API.
96+
// - The response must contain a single time series (or single value).
97+
// - Returns ErrMultipleValuesReturned when multiple series are found.
98+
// - Returns ErrNoValuesFound on missing/NaN values.
99+
func (p *SignozProvider) RunQuery(query string) (float64, error) {
100+
u, err := url.Parse("." + p.queryPath)
101+
if err != nil {
102+
return 0, fmt.Errorf("url.Parse failed: %w", err)
103+
}
104+
u.Path = path.Join(p.url.Path, u.Path)
105+
u = p.url.ResolveReference(u)
106+
107+
req, err := http.NewRequest("POST", u.String(), io.NopCloser(strings.NewReader(query)))
108+
if err != nil {
109+
return 0, fmt.Errorf("http.NewRequest failed: %w", err)
110+
}
111+
112+
if p.headers != nil {
113+
req.Header = p.headers
114+
}
115+
116+
req.Header.Set("Content-Type", "application/json")
117+
// No API key header expected; authentication is assumed unnecessary or handled externally.
118+
119+
ctx, cancel := context.WithTimeout(req.Context(), p.timeout)
120+
defer cancel()
121+
122+
r, err := p.client.Do(req.WithContext(ctx))
123+
if err != nil {
124+
return 0, fmt.Errorf("request failed: %w", err)
125+
}
126+
defer r.Body.Close()
127+
128+
b, err := io.ReadAll(r.Body)
129+
if err != nil {
130+
return 0, fmt.Errorf("error reading body: %w", err)
131+
}
132+
133+
if r.StatusCode >= 400 {
134+
return 0, fmt.Errorf("error response: %s", string(b))
135+
}
136+
137+
var resp signozResponse
138+
if err := json.Unmarshal(b, &resp); err != nil {
139+
return 0, fmt.Errorf("error unmarshaling result: %w, '%s'", err, string(b))
140+
}
141+
142+
// Determine the series to read from, ensuring single result
143+
if len(resp.Data.Result) == 0 {
144+
return 0, fmt.Errorf("%w", ErrNoValuesFound)
145+
}
146+
if len(resp.Data.Result) > 1 {
147+
return 0, fmt.Errorf("%w", ErrMultipleValuesReturned)
148+
}
149+
150+
result := resp.Data.Result[0]
151+
152+
// Prefer series[0].values if present (range response)
153+
if len(result.Series) > 1 {
154+
return 0, fmt.Errorf("%w", ErrMultipleValuesReturned)
155+
}
156+
if len(result.Series) == 1 {
157+
vals := result.Series[0].Values
158+
if len(vals) == 0 {
159+
return 0, fmt.Errorf("%w", ErrNoValuesFound)
160+
}
161+
v, err := parseValue(vals[len(vals)-1])
162+
if err != nil {
163+
return 0, err
164+
}
165+
if math.IsNaN(v) {
166+
return 0, fmt.Errorf("%w", ErrNoValuesFound)
167+
}
168+
return v, nil
169+
}
170+
171+
// Fallback to values (matrix) or value (vector)-like structures
172+
if len(result.Values) > 0 {
173+
v, err := parseValue(result.Values[len(result.Values)-1])
174+
if err != nil {
175+
return 0, err
176+
}
177+
if math.IsNaN(v) {
178+
return 0, fmt.Errorf("%w", ErrNoValuesFound)
179+
}
180+
return v, nil
181+
}
182+
if len(result.Value) == 2 {
183+
switch val := result.Value[1].(type) {
184+
case string:
185+
f, err := strconv.ParseFloat(val, 64)
186+
if err != nil {
187+
return 0, err
188+
}
189+
if math.IsNaN(f) {
190+
return 0, fmt.Errorf("%w", ErrNoValuesFound)
191+
}
192+
return f, nil
193+
case float64:
194+
if math.IsNaN(val) {
195+
return 0, fmt.Errorf("%w", ErrNoValuesFound)
196+
}
197+
return val, nil
198+
}
199+
}
200+
201+
return 0, fmt.Errorf("%w", ErrNoValuesFound)
202+
}
203+
204+
func parseValue(pair []interface{}) (float64, error) {
205+
if len(pair) != 2 {
206+
return 0, fmt.Errorf("invalid value pair")
207+
}
208+
switch v := pair[1].(type) {
209+
case string:
210+
return strconv.ParseFloat(v, 64)
211+
case float64:
212+
return v, nil
213+
default:
214+
return 0, fmt.Errorf("unsupported value type")
215+
}
216+
}
217+
218+
// IsOnline runs a minimal query and expects a value of 1
219+
func (p *SignozProvider) IsOnline() (bool, error) {
220+
now := time.Now().UnixMilli()
221+
body := fmt.Sprintf(`{"start": %d, "end": %d, "requestType": "time_series", "compositeQuery": {"queries": [{"type": "builder_formula", "spec": {"name": "F1", "expression": "1", "disabled": false}}]}}`, now-60000, now)
222+
223+
v, err := p.RunQuery(body)
224+
if err != nil {
225+
return false, fmt.Errorf("running query failed: %w", err)
226+
}
227+
if v != float64(1) {
228+
return false, fmt.Errorf("value is not 1 for query: builder_formula 1")
229+
}
230+
return true, nil
231+
}

0 commit comments

Comments
 (0)