fix(metrics): bound reason label cardinality for blocked responses (#2114)

This commit is contained in:
Dimitri Herzog
2026-06-15 19:32:57 +00:00
committed by GitHub
parent 9c5abdfcb2
commit 06555e04de
9 changed files with 177 additions and 33 deletions
+2 -2
View File
@@ -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,
+7
View File
@@ -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
+9
View File
@@ -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
View File
@@ -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(
+24 -5
View File
@@ -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, ", "))
+41
View File
@@ -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)"))
}
})
})
+7 -2
View File
@@ -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
+34 -23
View File
@@ -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
+47
View File
@@ -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{}