Files
zeekayandHanzo Dev d8a5046dab routing: rules, alerts, traces and sentry read the segment, not the router
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]>
2026-08-01 18:57:15 -07:00

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{}
}