mirror of
https://github.com/0xERR0R/blocky.git
synced 2026-10-11 21:59:54 +00:00
fix(metrics): bound reason label cardinality for blocked responses (#2114)
This commit is contained in:
@@ -1777,7 +1777,7 @@
|
|||||||
"legendFormat": "{{reason}}"
|
"legendFormat": "{{reason}}"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"description": "Detailed answer reason, including the upstream resolver or the denylist group that matched."
|
"description": "Answer reason, including the upstream resolver or the denylist group that matched. For blocked queries the `reason` label is group-level (to keep its cardinality bounded); the specific matched rule/domain is available in the query-log dashboard."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 22,
|
"id": 22,
|
||||||
@@ -2599,7 +2599,7 @@
|
|||||||
"legendFormat": "{{reason}}"
|
"legendFormat": "{{reason}}"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"description": "Which denylist groups blocked the most queries in the dashboard time range."
|
"description": "Which denylist groups blocked the most queries in the dashboard time range. The `reason` metric label is intentionally group-level to keep its cardinality bounded; for the specific matched rule/domain, use the query-log dashboard."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 30,
|
"id": 30,
|
||||||
|
|||||||
@@ -34,6 +34,13 @@ Following metrics will be exported:
|
|||||||
| blocky_rate_limit_cap_exhausted_total | Counter of queries dropped because the rate limiter bucket store was full |
|
| blocky_rate_limit_cap_exhausted_total | Counter of queries dropped because the rate limiter bucket store was full |
|
||||||
| blocky_rate_limit_active_buckets | Gauge of token buckets (≈ distinct clients) currently tracked by the rate limiter |
|
| blocky_rate_limit_active_buckets | Gauge of token buckets (≈ distinct clients) currently tracked by the rate limiter |
|
||||||
|
|
||||||
|
!!! note "`reason` label for blocked responses"
|
||||||
|
|
||||||
|
To keep the `reason` label of `blocky_response_total` bounded, blocked responses use the matched
|
||||||
|
group names only (e.g. `BLOCKED (ads)`), **not** the matched rule. The full reason including the
|
||||||
|
matched rule (e.g. `BLOCKED (ads: *.docler.com)`) is still available in the [query log](configuration.md#query-log).
|
||||||
|
This avoids unbounded metric cardinality when large deny lists are used.
|
||||||
|
|
||||||
### Grafana dashboard
|
### Grafana dashboard
|
||||||
|
|
||||||
Example [Grafana](https://grafana.com/) dashboard
|
Example [Grafana](https://grafana.com/) dashboard
|
||||||
|
|||||||
@@ -159,6 +159,15 @@ func HaveReason(reason string) types.GomegaMatcher {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func HaveReasonLabel(reasonLabel string) types.GomegaMatcher {
|
||||||
|
return gcustom.MakeMatcher(func(m *model.Response) (bool, error) {
|
||||||
|
return m.ReasonLabel == reasonLabel, nil
|
||||||
|
}).WithTemplate(
|
||||||
|
"Expected:\n{{.Actual}}\n{{.To}} have reason label:\n{{format .Data 1}}",
|
||||||
|
reasonLabel,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func HaveResponseType(c model.ResponseType) types.GomegaMatcher {
|
func HaveResponseType(c model.ResponseType) types.GomegaMatcher {
|
||||||
return gcustom.MakeMatcher(func(m *model.Response) (bool, error) {
|
return gcustom.MakeMatcher(func(m *model.Response) (bool, error) {
|
||||||
return m.RType == c, nil
|
return m.RType == c, nil
|
||||||
|
|||||||
+6
-1
@@ -53,7 +53,12 @@ func (t ResponseType) ToExtendedErrorCode() uint16 {
|
|||||||
type Response struct {
|
type Response struct {
|
||||||
Res *dns.Msg
|
Res *dns.Msg
|
||||||
Reason string
|
Reason string
|
||||||
RType ResponseType
|
// ReasonLabel is a low-cardinality variant of Reason, used as a Prometheus
|
||||||
|
// metric label. When empty, metrics fall back to Reason. Blocked responses
|
||||||
|
// set this to the matched group names only (without the matched rule), to
|
||||||
|
// keep the `reason` label bounded even with large deny lists.
|
||||||
|
ReasonLabel string
|
||||||
|
RType ResponseType
|
||||||
}
|
}
|
||||||
|
|
||||||
// RequestProtocol represents the server protocol ENUM(
|
// RequestProtocol represents the server protocol ENUM(
|
||||||
|
|||||||
@@ -426,9 +426,10 @@ func determineAllowlistOnlyGroups(cfg *config.Blocking) (result map[string]bool)
|
|||||||
|
|
||||||
// sets answer and/or return code for DNS response, if request should be blocked
|
// sets answer and/or return code for DNS response, if request should be blocked
|
||||||
func (r *BlockingResolver) handleBlocked(logger *logrus.Entry,
|
func (r *BlockingResolver) handleBlocked(logger *logrus.Entry,
|
||||||
request *model.Request, question dns.Question, reason string,
|
request *model.Request, question dns.Question, reason, reasonLabel string,
|
||||||
) (*model.Response, error) {
|
) (*model.Response, error) {
|
||||||
modelResp := model.NewResponseWithReason(request, model.ResponseTypeBLOCKED, reason)
|
modelResp := model.NewResponseWithReason(request, model.ResponseTypeBLOCKED, reason)
|
||||||
|
modelResp.ReasonLabel = reasonLabel
|
||||||
r.blockHandler.handleBlock(question, modelResp.Res)
|
r.blockHandler.handleBlock(question, modelResp.Res)
|
||||||
|
|
||||||
logger.Debugf("blocking request '%s'", reason)
|
logger.Debugf("blocking request '%s'", reason)
|
||||||
@@ -479,7 +480,7 @@ func (r *BlockingResolver) handleDenylist(ctx context.Context, groupsToCheck []s
|
|||||||
}
|
}
|
||||||
|
|
||||||
if allowlistOnlyAllowed {
|
if allowlistOnlyAllowed {
|
||||||
resp, err := r.handleBlocked(logger, request, question, "BLOCKED (ALLOWLIST ONLY)")
|
resp, err := r.handleBlocked(logger, request, question, "BLOCKED (ALLOWLIST ONLY)", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
err = fmt.Errorf("failed to handle allowlist-only block for %s: %w", domain, err)
|
err = fmt.Errorf("failed to handle allowlist-only block for %s: %w", domain, err)
|
||||||
}
|
}
|
||||||
@@ -488,7 +489,8 @@ func (r *BlockingResolver) handleDenylist(ctx context.Context, groupsToCheck []s
|
|||||||
}
|
}
|
||||||
|
|
||||||
if matches := r.matches(groupsToCheck, r.denylistMatcher, domain); len(matches) > 0 {
|
if matches := r.matches(groupsToCheck, r.denylistMatcher, domain); len(matches) > 0 {
|
||||||
resp, err := r.handleBlocked(logger, request, question, formatBlockReason(matches, ""))
|
resp, err := r.handleBlocked(logger, request, question,
|
||||||
|
formatBlockReason(matches, ""), formatBlockReasonLabel(matches, ""))
|
||||||
|
|
||||||
return true, resp, err
|
return true, resp, err
|
||||||
}
|
}
|
||||||
@@ -520,7 +522,8 @@ func (r *BlockingResolver) Resolve(ctx context.Context, request *model.Request)
|
|||||||
if matches := r.matches(groupsToCheck, r.allowlistMatcher, entryToCheck); len(matches) > 0 {
|
if matches := r.matches(groupsToCheck, r.allowlistMatcher, entryToCheck); len(matches) > 0 {
|
||||||
logger.WithField("matches", matches).Debugf("%s is allowlisted", tName)
|
logger.WithField("matches", matches).Debugf("%s is allowlisted", tName)
|
||||||
} else if matches := r.matches(groupsToCheck, r.denylistMatcher, entryToCheck); len(matches) > 0 {
|
} else if matches := r.matches(groupsToCheck, r.denylistMatcher, entryToCheck); len(matches) > 0 {
|
||||||
return r.handleBlocked(logger, request, request.Req.Question[0], formatBlockReason(matches, tName))
|
return r.handleBlocked(logger, request, request.Req.Question[0],
|
||||||
|
formatBlockReason(matches, tName), formatBlockReasonLabel(matches, tName))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -656,6 +659,18 @@ func (r *BlockingResolver) matches(groupsToCheck []string, m lists.Matcher,
|
|||||||
// "BLOCKED[ TYPE] (group: rule[, group: rule...])". Matched groups are sorted
|
// "BLOCKED[ TYPE] (group: rule[, group: rule...])". Matched groups are sorted
|
||||||
// so the rendered reason is deterministic when more than one group matches.
|
// so the rendered reason is deterministic when more than one group matches.
|
||||||
func formatBlockReason(matches map[string]string, typeName string) string {
|
func formatBlockReason(matches map[string]string, typeName string) string {
|
||||||
|
return renderBlockReason(matches, typeName, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatBlockReasonLabel renders a low-cardinality block reason of the form
|
||||||
|
// "BLOCKED[ TYPE] (group[, group...])" — group names only, without the matched
|
||||||
|
// rule. It is used as the `reason` Prometheus metric label so the label stays
|
||||||
|
// bounded by the number of configured groups, regardless of deny-list size.
|
||||||
|
func formatBlockReasonLabel(matches map[string]string, typeName string) string {
|
||||||
|
return renderBlockReason(matches, typeName, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderBlockReason(matches map[string]string, typeName string, includeRules bool) string {
|
||||||
reason := "BLOCKED"
|
reason := "BLOCKED"
|
||||||
if typeName != "" {
|
if typeName != "" {
|
||||||
reason += " " + typeName
|
reason += " " + typeName
|
||||||
@@ -667,7 +682,11 @@ func formatBlockReason(matches map[string]string, typeName string) string {
|
|||||||
|
|
||||||
entries := make([]string, 0, len(matches))
|
entries := make([]string, 0, len(matches))
|
||||||
for _, group := range slices.Sorted(maps.Keys(matches)) {
|
for _, group := range slices.Sorted(maps.Keys(matches)) {
|
||||||
entries = append(entries, fmt.Sprintf("%s: %s", group, matches[group]))
|
if includeRules {
|
||||||
|
entries = append(entries, fmt.Sprintf("%s: %s", group, matches[group]))
|
||||||
|
} else {
|
||||||
|
entries = append(entries, group)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Sprintf("%s (%s)", reason, strings.Join(entries, ", "))
|
return fmt.Sprintf("%s (%s)", reason, strings.Join(entries, ", "))
|
||||||
|
|||||||
@@ -285,6 +285,15 @@ var _ = Describe("BlockingResolver", Label("blockingResolver"), func() {
|
|||||||
HaveReturnCode(dns.RcodeSuccess),
|
HaveReturnCode(dns.RcodeSuccess),
|
||||||
))
|
))
|
||||||
})
|
})
|
||||||
|
It("should set a group-only ReasonLabel while keeping the detailed Reason", func() {
|
||||||
|
Expect(sut.Resolve(ctx, newRequestWithClient("domain1.com.", A, "1.2.1.2", "client1"))).
|
||||||
|
Should(
|
||||||
|
SatisfyAll(
|
||||||
|
HaveResponseType(ResponseTypeBLOCKED),
|
||||||
|
HaveReason("BLOCKED (gr1: domain1.com)"),
|
||||||
|
HaveReasonLabel("BLOCKED (gr1)"),
|
||||||
|
))
|
||||||
|
})
|
||||||
It("should block the A query if domain is on the denylist (multipart 1)", func() {
|
It("should block the A query if domain is on the denylist (multipart 1)", func() {
|
||||||
Expect(sut.Resolve(ctx, newRequestWithClient("domain1.com.", A, "1.2.1.2", "client2"))).
|
Expect(sut.Resolve(ctx, newRequestWithClient("domain1.com.", A, "1.2.1.2", "client2"))).
|
||||||
Should(
|
Should(
|
||||||
@@ -1542,3 +1551,35 @@ var _ = Describe("formatBlockReason", func() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
var _ = Describe("formatBlockReasonLabel", func() {
|
||||||
|
It("renders the matched group name only, without the rule", func() {
|
||||||
|
Expect(formatBlockReasonLabel(map[string]string{"gr1": "domain1.com"}, "")).
|
||||||
|
Should(Equal("BLOCKED (gr1)"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("includes the entry type when given (e.g. CNAME/IP)", func() {
|
||||||
|
Expect(formatBlockReasonLabel(map[string]string{"ads": "*.docler.com"}, "CNAME")).
|
||||||
|
Should(Equal("BLOCKED CNAME (ads)"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("keeps the label bounded: different rules in the same group yield the same label", func() {
|
||||||
|
// This is the whole point of the label: the rule (regex/domain) is
|
||||||
|
// unbounded, but the group set is not. Both must map to one metric series.
|
||||||
|
a := formatBlockReasonLabel(map[string]string{"ads": "/snapchat/"}, "")
|
||||||
|
b := formatBlockReasonLabel(map[string]string{"ads": "tracker.example.com"}, "")
|
||||||
|
|
||||||
|
Expect(a).Should(Equal("BLOCKED (ads)"))
|
||||||
|
Expect(b).Should(Equal(a))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("renders multiple matched groups sorted by group for deterministic output", func() {
|
||||||
|
matches := map[string]string{"zeta": "z.com", "alpha": "a.com", "mid": "m.com"}
|
||||||
|
|
||||||
|
// repeat to guard against Go's randomized map iteration order
|
||||||
|
for range 20 {
|
||||||
|
Expect(formatBlockReasonLabel(matches, "")).
|
||||||
|
Should(Equal("BLOCKED (alpha, mid, zeta)"))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -24,7 +24,12 @@ import (
|
|||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
const defaultCachingCleanUpInterval = 5 * time.Second
|
const (
|
||||||
|
defaultCachingCleanUpInterval = 5 * time.Second
|
||||||
|
|
||||||
|
// cachedReason is the response reason used for cache hits.
|
||||||
|
cachedReason = "CACHED"
|
||||||
|
)
|
||||||
|
|
||||||
// CacheDecorator optionally wraps the result cache (e.g., with Redis sync).
|
// CacheDecorator optionally wraps the result cache (e.g., with Redis sync).
|
||||||
type CacheDecorator func(cache.ExpiringCache[[]byte]) (cache.ExpiringCache[[]byte], error)
|
type CacheDecorator func(cache.ExpiringCache[[]byte]) (cache.ExpiringCache[[]byte], error)
|
||||||
@@ -217,7 +222,7 @@ func (r *CachingResolver) Resolve(ctx context.Context, request *model.Request) (
|
|||||||
setTTLInCachedResponse(val, ttl)
|
setTTLInCachedResponse(val, ttl)
|
||||||
|
|
||||||
if val.Rcode == dns.RcodeSuccess {
|
if val.Rcode == dns.RcodeSuccess {
|
||||||
return &model.Response{Res: val, RType: model.ResponseTypeCACHED, Reason: "CACHED"}, nil
|
return &model.Response{Res: val, RType: model.ResponseTypeCACHED, Reason: cachedReason}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return &model.Response{Res: val, RType: model.ResponseTypeCACHED, Reason: "CACHED NEGATIVE"}, nil
|
return &model.Response{Res: val, RType: model.ResponseTypeCACHED, Reason: "CACHED NEGATIVE"}, nil
|
||||||
|
|||||||
@@ -41,30 +41,41 @@ type MetricsResolver struct {
|
|||||||
func (r *MetricsResolver) Resolve(ctx context.Context, request *model.Request) (*model.Response, error) {
|
func (r *MetricsResolver) Resolve(ctx context.Context, request *model.Request) (*model.Response, error) {
|
||||||
response, err := r.next.Resolve(ctx, request)
|
response, err := r.next.Resolve(ctx, request)
|
||||||
|
|
||||||
if r.cfg.Enable {
|
if !r.cfg.Enable {
|
||||||
r.totalQueries.With(prometheus.Labels{
|
return response, err
|
||||||
labelClient: strings.Join(request.ClientNames, ","),
|
}
|
||||||
labelType: dns.TypeToString[request.Req.Question[0].Qtype],
|
|
||||||
|
r.totalQueries.With(prometheus.Labels{
|
||||||
|
labelClient: strings.Join(request.ClientNames, ","),
|
||||||
|
labelType: dns.TypeToString[request.Req.Question[0].Qtype],
|
||||||
|
}).Inc()
|
||||||
|
|
||||||
|
reqDuration := time.Since(request.RequestTS)
|
||||||
|
responseType := "err"
|
||||||
|
|
||||||
|
if response != nil {
|
||||||
|
responseType = response.RType.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
r.durationHistogram.WithLabelValues(responseType).Observe(reqDuration.Seconds())
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
r.totalErrors.Inc()
|
||||||
|
} else {
|
||||||
|
// Prefer the low-cardinality ReasonLabel for the metric label; fall
|
||||||
|
// back to Reason when it is not set. Blocked responses embed the
|
||||||
|
// matched rule in Reason (e.g. a regex or domain), which is unbounded
|
||||||
|
// for large deny lists, so they set ReasonLabel to the group names only.
|
||||||
|
reasonLabel := response.ReasonLabel
|
||||||
|
if reasonLabel == "" {
|
||||||
|
reasonLabel = response.Reason
|
||||||
|
}
|
||||||
|
|
||||||
|
r.totalResponse.With(prometheus.Labels{
|
||||||
|
labelReason: reasonLabel,
|
||||||
|
labelResponseCode: dns.RcodeToString[response.Res.Rcode],
|
||||||
|
labelResponseType: response.RType.String(),
|
||||||
}).Inc()
|
}).Inc()
|
||||||
|
|
||||||
reqDuration := time.Since(request.RequestTS)
|
|
||||||
responseType := "err"
|
|
||||||
|
|
||||||
if response != nil {
|
|
||||||
responseType = response.RType.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
r.durationHistogram.WithLabelValues(responseType).Observe(reqDuration.Seconds())
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
r.totalErrors.Inc()
|
|
||||||
} else {
|
|
||||||
r.totalResponse.With(prometheus.Labels{
|
|
||||||
labelReason: response.Reason,
|
|
||||||
labelResponseCode: dns.RcodeToString[response.Res.Rcode],
|
|
||||||
labelResponseType: response.RType.String(),
|
|
||||||
}).Inc()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return response, err
|
return response, err
|
||||||
|
|||||||
@@ -77,6 +77,53 @@ var _ = Describe("MetricResolver", func() {
|
|||||||
m.AssertExpectations(GinkgoT())
|
m.AssertExpectations(GinkgoT())
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
When("Response has a low-cardinality ReasonLabel", func() {
|
||||||
|
BeforeEach(func() {
|
||||||
|
m = &mockResolver{}
|
||||||
|
m.On("Resolve", mock.Anything).Return(&Response{
|
||||||
|
Res: new(dns.Msg),
|
||||||
|
RType: ResponseTypeBLOCKED,
|
||||||
|
Reason: "BLOCKED (ads: /snapchat/)",
|
||||||
|
ReasonLabel: "BLOCKED (ads)",
|
||||||
|
}, nil)
|
||||||
|
sut.Next(m)
|
||||||
|
})
|
||||||
|
It("uses ReasonLabel for the reason metric label, not the detailed Reason", func() {
|
||||||
|
_, err := sut.Resolve(ctx, newRequestWithClient("example.com.", A, "", "client"))
|
||||||
|
Expect(err).Should(Succeed())
|
||||||
|
|
||||||
|
cnt, err := sut.totalResponse.GetMetricWith(prometheus.Labels{
|
||||||
|
"reason": "BLOCKED (ads)",
|
||||||
|
"response_code": "NOERROR",
|
||||||
|
"response_type": "BLOCKED",
|
||||||
|
})
|
||||||
|
Expect(err).Should(Succeed())
|
||||||
|
Expect(testutil.ToFloat64(cnt)).Should(BeNumerically("==", 1))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
When("Response has no ReasonLabel", func() {
|
||||||
|
BeforeEach(func() {
|
||||||
|
m = &mockResolver{}
|
||||||
|
m.On("Resolve", mock.Anything).Return(&Response{
|
||||||
|
Res: new(dns.Msg),
|
||||||
|
RType: ResponseTypeRESOLVED,
|
||||||
|
Reason: "CACHED",
|
||||||
|
}, nil)
|
||||||
|
sut.Next(m)
|
||||||
|
})
|
||||||
|
It("falls back to Reason for the reason metric label", func() {
|
||||||
|
_, err := sut.Resolve(ctx, newRequestWithClient("example.com.", A, "", "client"))
|
||||||
|
Expect(err).Should(Succeed())
|
||||||
|
|
||||||
|
cnt, err := sut.totalResponse.GetMetricWith(prometheus.Labels{
|
||||||
|
"reason": "CACHED",
|
||||||
|
"response_code": "NOERROR",
|
||||||
|
"response_type": "RESOLVED",
|
||||||
|
})
|
||||||
|
Expect(err).Should(Succeed())
|
||||||
|
Expect(testutil.ToFloat64(cnt)).Should(BeNumerically("==", 1))
|
||||||
|
})
|
||||||
|
})
|
||||||
When("Error occurs while request processing", func() {
|
When("Error occurs while request processing", func() {
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
m = &mockResolver{}
|
m = &mockResolver{}
|
||||||
|
|||||||
Reference in New Issue
Block a user