Skip to content

Commit 2b163c2

Browse files
committed
feat: introduce provider for signoz
Signed-off-by: Pranav <[email protected]>
1 parent 3a27fd1 commit 2b163c2

File tree

3 files changed

+531
-0
lines changed

3 files changed

+531
-0
lines changed

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: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
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+
apiKey string
45+
client *http.Client
46+
queryPath string
47+
}
48+
49+
// signozResponse models a flexible subset of SigNoz responses
50+
// It supports both single value and range values under common fields.
51+
type signozResponse struct {
52+
Data struct {
53+
Result []struct {
54+
// Prometheus-like compatibility
55+
Value []interface{} `json:"value"`
56+
Values [][]interface{} `json:"values"`
57+
58+
// SigNoz series array
59+
Series []struct {
60+
Values [][]interface{} `json:"values"`
61+
} `json:"series"`
62+
} `json:"result"`
63+
} `json:"data"`
64+
}
65+
66+
// NewSignozProvider takes a provider spec and the credentials map,
67+
// validates the address, extracts the API key from the provided Secret,
68+
// and returns a client ready to execute requests against the SigNoz API.
69+
func NewSignozProvider(provider flaggerv1.MetricTemplateProvider, credentials map[string][]byte) (*SignozProvider, error) {
70+
signozURL, err := url.Parse(provider.Address)
71+
if provider.Address == "" || err != nil {
72+
return nil, fmt.Errorf("%s address %s is not a valid URL", provider.Type, provider.Address)
73+
}
74+
75+
sp := SignozProvider{
76+
timeout: 5 * time.Second,
77+
url: *signozURL,
78+
headers: provider.Headers,
79+
client: http.DefaultClient,
80+
queryPath: SignozAPIPath,
81+
}
82+
83+
if provider.InsecureSkipVerify {
84+
t := http.DefaultTransport.(*http.Transport).Clone()
85+
t.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
86+
sp.client = &http.Client{Transport: t}
87+
}
88+
89+
if provider.SecretRef != nil {
90+
if apiKey, ok := credentials["apiKey"]; ok {
91+
sp.apiKey = string(apiKey)
92+
} else {
93+
return nil, fmt.Errorf("%s credentials does not contain %s", provider.Type, "apiKey")
94+
}
95+
}
96+
97+
return &sp, nil
98+
}
99+
100+
// RunQuery posts the provided JSON payload to SigNoz query_range and
101+
// returns a single float64 value derived from the response.
102+
//
103+
// Expectations:
104+
// - The input `query` is a valid JSON document per SigNoz Query Range API.
105+
// - The response must contain a single time series (or single value).
106+
// - Returns ErrMultipleValuesReturned when multiple series are found.
107+
// - Returns ErrNoValuesFound on missing/NaN values.
108+
func (p *SignozProvider) RunQuery(query string) (float64, error) {
109+
u, err := url.Parse("." + p.queryPath)
110+
if err != nil {
111+
return 0, fmt.Errorf("url.Parse failed: %w", err)
112+
}
113+
u.Path = path.Join(p.url.Path, u.Path)
114+
u = p.url.ResolveReference(u)
115+
116+
req, err := http.NewRequest("POST", u.String(), io.NopCloser(strings.NewReader(query)))
117+
if err != nil {
118+
return 0, fmt.Errorf("http.NewRequest failed: %w", err)
119+
}
120+
121+
if p.headers != nil {
122+
req.Header = p.headers
123+
}
124+
125+
req.Header.Set("Content-Type", "application/json")
126+
if p.apiKey != "" {
127+
req.Header.Set("SIGNOZ-API-KEY", p.apiKey)
128+
}
129+
130+
ctx, cancel := context.WithTimeout(req.Context(), p.timeout)
131+
defer cancel()
132+
133+
r, err := p.client.Do(req.WithContext(ctx))
134+
if err != nil {
135+
return 0, fmt.Errorf("request failed: %w", err)
136+
}
137+
defer r.Body.Close()
138+
139+
b, err := io.ReadAll(r.Body)
140+
if err != nil {
141+
return 0, fmt.Errorf("error reading body: %w", err)
142+
}
143+
144+
if r.StatusCode >= 400 {
145+
return 0, fmt.Errorf("error response: %s", string(b))
146+
}
147+
148+
var resp signozResponse
149+
if err := json.Unmarshal(b, &resp); err != nil {
150+
return 0, fmt.Errorf("error unmarshaling result: %w, '%s'", err, string(b))
151+
}
152+
153+
// Determine the series to read from, ensuring single result
154+
if len(resp.Data.Result) == 0 {
155+
return 0, fmt.Errorf("%w", ErrNoValuesFound)
156+
}
157+
if len(resp.Data.Result) > 1 {
158+
return 0, fmt.Errorf("%w", ErrMultipleValuesReturned)
159+
}
160+
161+
result := resp.Data.Result[0]
162+
163+
// Prefer series[0].values if present (range response)
164+
if len(result.Series) > 1 {
165+
return 0, fmt.Errorf("%w", ErrMultipleValuesReturned)
166+
}
167+
if len(result.Series) == 1 {
168+
vals := result.Series[0].Values
169+
if len(vals) == 0 {
170+
return 0, fmt.Errorf("%w", ErrNoValuesFound)
171+
}
172+
v, err := parseValue(vals[len(vals)-1])
173+
if err != nil {
174+
return 0, err
175+
}
176+
if math.IsNaN(v) {
177+
return 0, fmt.Errorf("%w", ErrNoValuesFound)
178+
}
179+
return v, nil
180+
}
181+
182+
// Fallback to values (matrix) or value (vector)-like structures
183+
if len(result.Values) > 0 {
184+
v, err := parseValue(result.Values[len(result.Values)-1])
185+
if err != nil {
186+
return 0, err
187+
}
188+
if math.IsNaN(v) {
189+
return 0, fmt.Errorf("%w", ErrNoValuesFound)
190+
}
191+
return v, nil
192+
}
193+
if len(result.Value) == 2 {
194+
switch val := result.Value[1].(type) {
195+
case string:
196+
f, err := strconv.ParseFloat(val, 64)
197+
if err != nil {
198+
return 0, err
199+
}
200+
if math.IsNaN(f) {
201+
return 0, fmt.Errorf("%w", ErrNoValuesFound)
202+
}
203+
return f, nil
204+
case float64:
205+
if math.IsNaN(val) {
206+
return 0, fmt.Errorf("%w", ErrNoValuesFound)
207+
}
208+
return val, nil
209+
}
210+
}
211+
212+
return 0, fmt.Errorf("%w", ErrNoValuesFound)
213+
}
214+
215+
func parseValue(pair []interface{}) (float64, error) {
216+
if len(pair) != 2 {
217+
return 0, fmt.Errorf("invalid value pair")
218+
}
219+
switch v := pair[1].(type) {
220+
case string:
221+
return strconv.ParseFloat(v, 64)
222+
case float64:
223+
return v, nil
224+
default:
225+
return 0, fmt.Errorf("unsupported value type")
226+
}
227+
}
228+
229+
// IsOnline runs a minimal query and expects a value of 1
230+
func (p *SignozProvider) IsOnline() (bool, error) {
231+
now := time.Now().UnixMilli()
232+
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)
233+
234+
v, err := p.RunQuery(body)
235+
if err != nil {
236+
return false, fmt.Errorf("running query failed: %w", err)
237+
}
238+
if v != float64(1) {
239+
return false, fmt.Errorf("value is not 1 for query: builder_formula 1")
240+
}
241+
return true, nil
242+
}

0 commit comments

Comments
 (0)