mirror of
https://github.com/hanzoai/o11y.git
synced 2026-11-03 02:04:48 +00:00
Twenty-nine handler bodies across six packages named gorilla/mux to read a path
segment, so the router was braided into the rule, alert, trace and sentry logic
that addresses a resource by id. The value each one needs is the SEGMENT; which
router matched the route is not a fact these handlers should hold. All twenty-
nine now read through coretypes.Param — the one route-value reader — and the
tracefunnel test's two injectors through coretypes.SetParams, its writer half.
A PATH segment and a QUERY value stay apart, because that is the failure this
seam exists to prevent: r.URL.Query().Get is untouched everywhere it appears
(the history window, sentry's ?project=, the funnel limits), and three of the
new tests pin it by sending a query value of the SAME name as the path segment
and asserting the path still answers.
Two helpers, both deleting real duplication rather than adding a second reader:
o11yruler.idFromPath replaces the same five-line uuid-v7 block written out
seven times, and o11yalertmanager.channelIDFromPath replaces a seventeen-line
nil-map/ok/parse dance written out three times. Absent and malformed remain
DIFFERENT refusals in the alertmanager one — "id is required in path" vs "id is
not a valid uuid-v7" — which is what the code it replaces answered.
Eleven new tests, and they are not vacuous. Each drives a REAL router at the
REAL template — /v1/o11y/rules/{id}/history/stats, /v1/o11y/channels/{id},
/v1/o11y/traces/{traceID}/waterfall, /v1/sentry/events/{id}, the uuid-
constrained /v1/sentry/{project}/envelope/ — and asserts the MODULE was asked
about what the router matched, so a passing test needs the whole path from
route table to business call. Stubbing Param to "" (go test -overlay, so the
shared tree is never mutated) turns all six packages RED; before these tests
five of them had no test that touched a param at all.
Registration is untouched: no {id} literal, no *mux.Router, no .Methods() moved.
Route table unchanged — TestEveryRouteIsNamedAndCounted still passes at 367.
Co-authored-by: Hanzo Dev <[email protected]>
322 lines
9.6 KiB
Go
322 lines
9.6 KiB
Go
package implrulestatehistory
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/hanzoai/o11y/pkg/errors"
|
|
"github.com/hanzoai/o11y/pkg/http/binding"
|
|
"github.com/hanzoai/o11y/pkg/http/render"
|
|
"github.com/hanzoai/o11y/pkg/modules/rulestatehistory"
|
|
"github.com/hanzoai/o11y/pkg/types/coretypes"
|
|
qbtypes "github.com/hanzoai/o11y/pkg/types/querybuildertypes/querybuildertypesv5"
|
|
"github.com/hanzoai/o11y/pkg/types/rulestatehistorytypes"
|
|
"github.com/hanzoai/o11y/pkg/types/telemetrytypes"
|
|
)
|
|
|
|
type handler struct {
|
|
module rulestatehistory.Module
|
|
}
|
|
|
|
type ruleHistoryRequest struct {
|
|
Query rulestatehistorytypes.Query
|
|
Cursor string
|
|
}
|
|
|
|
type cursorToken struct {
|
|
Offset int64 `json:"offset"`
|
|
Limit int64 `json:"limit"`
|
|
}
|
|
|
|
func NewHandler(module rulestatehistory.Module) rulestatehistory.Handler {
|
|
return &handler{module: module}
|
|
}
|
|
|
|
func (h *handler) GetRuleHistoryStats(w http.ResponseWriter, r *http.Request) {
|
|
ruleID := coretypes.Param(r, "id")
|
|
query, ok := h.parseV2BaseQueryRequest(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
stats, err := h.module.GetHistoryStats(r.Context(), ruleID, query)
|
|
if err != nil {
|
|
render.Error(w, err)
|
|
return
|
|
}
|
|
render.Success(w, http.StatusOK, stats)
|
|
}
|
|
|
|
func (h *handler) GetRuleHistoryOverallStatus(w http.ResponseWriter, r *http.Request) {
|
|
ruleID := coretypes.Param(r, "id")
|
|
query, ok := h.parseV2BaseQueryRequest(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
res, err := h.module.GetHistoryOverallStatus(r.Context(), ruleID, query)
|
|
if err != nil {
|
|
render.Error(w, err)
|
|
return
|
|
}
|
|
render.Success(w, http.StatusOK, res)
|
|
}
|
|
|
|
func (h *handler) GetRuleHistoryTimeline(w http.ResponseWriter, r *http.Request) {
|
|
ruleID := coretypes.Param(r, "id")
|
|
req, ok := h.parseV2TimelineQueryRequest(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if req.Cursor != "" {
|
|
token, err := decodeCursor(req.Cursor)
|
|
if err != nil {
|
|
render.Error(w, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "invalid cursor"))
|
|
return
|
|
}
|
|
req.Query.Offset = token.Offset
|
|
if req.Query.Limit == 0 {
|
|
req.Query.Limit = token.Limit
|
|
}
|
|
}
|
|
if req.Query.Limit == 0 {
|
|
req.Query.Limit = 50
|
|
}
|
|
|
|
timelineItems, timelineTotal, err := h.module.GetHistoryTimeline(r.Context(), ruleID, req.Query)
|
|
if err != nil {
|
|
render.Error(w, err)
|
|
return
|
|
}
|
|
|
|
resp := rulestatehistorytypes.GettableRuleStateTimeline{}
|
|
resp.Items = make([]rulestatehistorytypes.GettableRuleStateHistory, 0, len(timelineItems))
|
|
for _, item := range timelineItems {
|
|
resp.Items = append(resp.Items, rulestatehistorytypes.GettableRuleStateHistory{
|
|
RuleID: item.RuleID,
|
|
RuleName: item.RuleName,
|
|
OverallState: item.OverallState,
|
|
OverallStateChanged: item.OverallStateChanged,
|
|
State: item.State,
|
|
StateChanged: item.StateChanged,
|
|
UnixMilli: item.UnixMilli,
|
|
Labels: item.Labels.ToQBLabels(),
|
|
Fingerprint: item.Fingerprint,
|
|
Value: item.Value,
|
|
})
|
|
}
|
|
resp.Total = timelineTotal
|
|
if req.Query.Limit > 0 && req.Query.Offset+int64(len(timelineItems)) < int64(timelineTotal) {
|
|
nextOffset := req.Query.Offset + int64(len(timelineItems))
|
|
nextCursor, err := encodeCursor(cursorToken{Offset: nextOffset, Limit: req.Query.Limit})
|
|
if err != nil {
|
|
render.Error(w, err)
|
|
return
|
|
}
|
|
resp.NextCursor = nextCursor
|
|
}
|
|
render.Success(w, http.StatusOK, resp)
|
|
}
|
|
|
|
func (h *handler) GetRuleHistoryContributors(w http.ResponseWriter, r *http.Request) {
|
|
ruleID := coretypes.Param(r, "id")
|
|
query, ok := h.parseV2BaseQueryRequest(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
res, err := h.module.GetHistoryContributors(r.Context(), ruleID, query)
|
|
if err != nil {
|
|
render.Error(w, err)
|
|
return
|
|
}
|
|
converted := make([]rulestatehistorytypes.GettableRuleStateHistoryContributor, 0, len(res))
|
|
for _, item := range res {
|
|
converted = append(converted, rulestatehistorytypes.GettableRuleStateHistoryContributor{
|
|
Fingerprint: item.Fingerprint,
|
|
Labels: item.Labels.ToQBLabels(),
|
|
Count: item.Count,
|
|
RelatedTracesLink: item.RelatedTracesLink,
|
|
RelatedLogsLink: item.RelatedLogsLink,
|
|
})
|
|
}
|
|
render.Success(w, http.StatusOK, converted)
|
|
}
|
|
|
|
func (h *handler) GetRuleHistoryFilterKeys(w http.ResponseWriter, r *http.Request) {
|
|
ruleID := coretypes.Param(r, "id")
|
|
query, search, limit, ok := h.parseV2FilterKeysRequest(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
res, err := h.module.GetHistoryFilterKeys(r.Context(), ruleID, query, search, limit)
|
|
if err != nil {
|
|
render.Error(w, err)
|
|
return
|
|
}
|
|
render.Success(w, http.StatusOK, res)
|
|
}
|
|
|
|
func (h *handler) GetRuleHistoryFilterValues(w http.ResponseWriter, r *http.Request) {
|
|
ruleID := coretypes.Param(r, "id")
|
|
query, key, search, limit, ok := h.parseV2FilterValuesRequest(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
res, err := h.module.GetHistoryFilterValues(r.Context(), ruleID, key, query, search, limit)
|
|
if err != nil {
|
|
render.Error(w, err)
|
|
return
|
|
}
|
|
render.Success(w, http.StatusOK, res)
|
|
}
|
|
|
|
func (h *handler) parseV2BaseQueryRequest(w http.ResponseWriter, r *http.Request) (rulestatehistorytypes.Query, bool) {
|
|
query, err := parseV2BaseQueryFromURL(r)
|
|
if err != nil {
|
|
render.Error(w, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "invalid query parameters"))
|
|
return rulestatehistorytypes.Query{}, false
|
|
}
|
|
if query.Start == 0 || query.End == 0 || query.Start >= query.End {
|
|
render.Error(w, errors.NewInvalidInputf(errors.CodeInvalidInput, "start and end are required and start must be less than end"))
|
|
return rulestatehistorytypes.Query{}, false
|
|
}
|
|
return query, true
|
|
}
|
|
|
|
func (h *handler) parseV2TimelineQueryRequest(w http.ResponseWriter, r *http.Request) (*ruleHistoryRequest, bool) {
|
|
req, err := parseV2TimelineQueryFromURL(r)
|
|
if err != nil {
|
|
render.Error(w, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "invalid query parameters"))
|
|
return nil, false
|
|
}
|
|
if err := req.Query.Validate(); err != nil {
|
|
render.Error(w, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "invalid query parameters"))
|
|
return nil, false
|
|
}
|
|
return req, true
|
|
}
|
|
|
|
func (h *handler) parseV2FilterKeysRequest(w http.ResponseWriter, r *http.Request) (rulestatehistorytypes.Query, string, int64, bool) {
|
|
raw := telemetrytypes.PostableFieldKeysParams{}
|
|
if err := binding.Query.BindQuery(r.URL.Query(), &raw); err != nil {
|
|
render.Error(w, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "invalid query parameters"))
|
|
return rulestatehistorytypes.Query{}, "", 0, false
|
|
}
|
|
|
|
query := rulestatehistorytypes.Query{
|
|
Start: raw.StartUnixMilli,
|
|
End: raw.EndUnixMilli,
|
|
FilterExpression: qbtypes.Filter{},
|
|
Order: qbtypes.OrderDirectionAsc,
|
|
}
|
|
if err := query.Validate(); err != nil {
|
|
render.Error(w, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "invalid query parameters"))
|
|
return rulestatehistorytypes.Query{}, "", 0, false
|
|
}
|
|
|
|
limit := normalizeFilterLimit(int64(raw.Limit))
|
|
return query, strings.TrimSpace(raw.SearchText), limit, true
|
|
}
|
|
|
|
func (h *handler) parseV2FilterValuesRequest(w http.ResponseWriter, r *http.Request) (rulestatehistorytypes.Query, string, string, int64, bool) {
|
|
raw := telemetrytypes.PostableFieldValueParams{}
|
|
if err := binding.Query.BindQuery(r.URL.Query(), &raw); err != nil {
|
|
render.Error(w, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "invalid query parameters"))
|
|
return rulestatehistorytypes.Query{}, "", "", 0, false
|
|
}
|
|
|
|
key := strings.TrimSpace(raw.Name)
|
|
if key == "" {
|
|
render.Error(w, errors.NewInvalidInputf(errors.CodeInvalidInput, "key is required"))
|
|
return rulestatehistorytypes.Query{}, "", "", 0, false
|
|
}
|
|
|
|
query := rulestatehistorytypes.Query{
|
|
Start: raw.StartUnixMilli,
|
|
End: raw.EndUnixMilli,
|
|
FilterExpression: parseFilterExpression(raw.ExistingQuery),
|
|
Order: qbtypes.OrderDirectionAsc,
|
|
}
|
|
if err := query.Validate(); err != nil {
|
|
render.Error(w, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "invalid query parameters"))
|
|
return rulestatehistorytypes.Query{}, "", "", 0, false
|
|
}
|
|
|
|
limit := normalizeFilterLimit(int64(raw.Limit))
|
|
return query, key, strings.TrimSpace(raw.SearchText), limit, true
|
|
}
|
|
|
|
func parseV2BaseQueryFromURL(r *http.Request) (rulestatehistorytypes.Query, error) {
|
|
raw := rulestatehistorytypes.PostableRuleStateHistoryBaseQuery{}
|
|
if err := binding.Query.BindQuery(r.URL.Query(), &raw); err != nil {
|
|
return rulestatehistorytypes.Query{}, err
|
|
}
|
|
|
|
return rulestatehistorytypes.Query{
|
|
Start: raw.Start,
|
|
End: raw.End,
|
|
}, nil
|
|
}
|
|
|
|
func parseV2TimelineQueryFromURL(r *http.Request) (*ruleHistoryRequest, error) {
|
|
raw := rulestatehistorytypes.PostableRuleStateHistoryTimelineQuery{}
|
|
if err := binding.Query.BindQuery(r.URL.Query(), &raw); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
req := &ruleHistoryRequest{}
|
|
req.Query.Start = raw.Start
|
|
req.Query.End = raw.End
|
|
req.Query.State = raw.State
|
|
req.Query.Limit = raw.Limit
|
|
req.Query.Order = raw.Order
|
|
req.Query.FilterExpression = parseFilterExpression(raw.FilterExpression)
|
|
req.Cursor = raw.Cursor
|
|
return req, nil
|
|
}
|
|
|
|
func encodeCursor(token cursorToken) (string, error) {
|
|
data, err := json.Marshal(token)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return base64.RawURLEncoding.EncodeToString(data), nil
|
|
}
|
|
|
|
func decodeCursor(cursor string) (*cursorToken, error) {
|
|
data, err := base64.RawURLEncoding.DecodeString(cursor)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
token := &cursorToken{}
|
|
if err := json.Unmarshal(data, token); err != nil {
|
|
return nil, err
|
|
}
|
|
return token, nil
|
|
}
|
|
|
|
func normalizeFilterLimit(limit int64) int64 {
|
|
if limit <= 0 {
|
|
return 50
|
|
}
|
|
if limit > 200 {
|
|
return 200
|
|
}
|
|
return limit
|
|
}
|
|
|
|
func parseFilterExpression(values ...string) qbtypes.Filter {
|
|
for _, value := range values {
|
|
expr := strings.TrimSpace(value)
|
|
if expr != "" {
|
|
return qbtypes.Filter{Expression: expr}
|
|
}
|
|
}
|
|
return qbtypes.Filter{}
|
|
}
|