Skip to content

feat(experimental): pluggable TelemetrySink interface for engine events#1616

Draft
jptosso wants to merge 1 commit into
corazawaf:mainfrom
jptosso:feat/telemetry-sink
Draft

feat(experimental): pluggable TelemetrySink interface for engine events#1616
jptosso wants to merge 1 commit into
corazawaf:mainfrom
jptosso:feat/telemetry-sink

Conversation

@jptosso

@jptosso jptosso commented Apr 24, 2026

Copy link
Copy Markdown
Member

Summary

Adds a minimal, opt-in TelemetrySink interface that lets connectors (coraza-caddy, coraza-proxy-wasm, coraza-spoa, libcoraza) route coraza engine events into their native observability stacks. Coraza core ships the hooks; it does not ship a Prometheus client, an OTel SDK, or any log schema. Zero new dependencies in go.mod.

Performance impact for non-opt-in users

Measured on Apple M4, go test -bench -benchtime=5s -count=10, identical 2-rule transaction shape on both branches:

main (no telemetry code) this branch (telemetry disabled) Δ
Mean ns/op 3,241.6 3,271.4 +29.8 ns (+0.92%)
Allocations 74 74 0
Bytes ~3,974 ~3,973 ≈0

The ~30 ns delta corresponds to 6 nil-pointer checks at phase boundaries plus one atomic.Bool.Load() per phase. No heap allocation, no semantic change when no sink is attached.

Motivation

Today coraza is invisible to operators. No phase timings, no per-rule match counters, no structured hook for "is the engine healthy." The existing ErrorLogCb is called only on matched rules with log:true — an operator cannot answer "is coraza 30 ms of my 40 ms tail?" or "did my rule change double the block rate?" without writing bespoke glue.

Events emitted

Core ships five events. Sinks turn them into metrics, logs, or spans in the operator's native stack. Coraza makes no assumption about which.

EngineReadyEvent — once per WAF, at end of NewWAF

Field Type Meaning
RulesLoaded int Number of compiled SecLang rules after directive parsing
InitDuration time.Duration Wall-clock time spent inside NewWAF

TransactionEvent — at transaction start, each phase end, and transaction close

Field Type Meaning
Kind TransactionEventKind start | phase_end | finish
TransactionID string Caller-supplied or auto-generated id
Context context.Context Parent request context (for OTel span chaining)
Phase types.RulePhase Only meaningful on phase_end
PhaseDuration time.Duration Time spent inside the phase
RulesEvaluated int Rules actually executed in that phase
Interruption *types.Interruption Terminal decision on finish; current state on phase_end

RuleMatchEvent — once per rule match, synchronously after match record is appended

Field Type Meaning
TransactionID string
Context context.Context
Rule types.RuleMetadata Full rule metadata (id, tags, severity, …)
Phase types.RulePhase
Action string "deny" | "drop" | "pass" | "allow" | "redirect" | "" (no disruptive action)
Enforced bool True when the engine actually applied the action (false in DetectionOnly)
Data []types.MatchData Matched variables + values (attacker-controlled — sinks must not use as metric labels)

EngineEvent — on engine-internal failures

Field Type Meaning
Kind EngineEventKind init | parse | body_processor | body_truncated | transaction
TransactionID string Empty for init/parse errors
Phase types.RulePhase Zero when not phase-scoped
Err error Underlying error, may be nil
Message string Human-readable description

RuleTimingEvent — opt-in per-rule timing (see dedicated section below)

Field Type Meaning
TransactionID string
Rule types.RuleMetadata
Phase types.RulePhase
Duration time.Duration Full rule evaluation cost (variables, transformations, operator, actions)
Matched bool Did this invocation produce a match record?

Per-rule timing — opt-in, investigation-only

Per-rule timing answers the question "which specific rule is making phase 2 slow?". It is deliberately separated from the base telemetry for three reasons: high cardinality (one event per rule per request — at CRS full that's ~450), high frequency, and measurable hot-path cost when active.

How to enable it

Two APIs, one atomic flag under the hood.

At startup:

cfg := coraza.NewWAFConfig().WithDirectives(rules)
cfg = experimental.WAFConfigWithTelemetrySink(cfg, mySink)
cfg = experimental.WAFConfigWithPerRuleTiming(cfg, true)
waf, _ := coraza.NewWAF(cfg)

At runtime — flip on a live WAF when an alert fires, flip off when investigation is done:

experimental.WAFEnablePerRuleTiming(waf, true)
// ...5 minutes of observation...
experimental.WAFEnablePerRuleTiming(waf, false)

Safe to call concurrently with active transactions; the change is observed on the next rule-evaluation loop.

How the sink consumes it

RuleTimingEvent is delivered via a separate optional interface. A sink opts in by implementing both TelemetrySink and RuleTimingSink:

type mySink struct{ /* the 4 regular methods */ }

// Optional extension — implement only if you want per-rule timing:
func (s *mySink) OnRuleTimed(ev plugintypes.RuleTimingEvent) {
    ruleDurSeconds.WithLabelValues(strconv.Itoa(ev.Rule.ID())).Observe(ev.Duration.Seconds())
}

A sink that does NOT implement RuleTimingSink is fine — the engine type-asserts once at SetTelemetrySink time and caches the result, so sinks without the extension pay zero runtime cost even when the flag is toggled on.

Performance

State Cost per transaction What you pay for
Off (default) ~1 ns per phase Single atomic.Bool.Load() + cached pointer compare
On ~75 ns per rule evaluated Two time.Now() calls + one interface dispatch

At CRS paranoia 2 (~450 rules across phases) the on-state cost is roughly +34 µs per transaction — around 10% of baseline. Acceptable for a 5-minute debug window, too much for always-on. The off-state cost is sub-1% and was measured at +1.0% over the counting sink across 5 benchmark runs — within noise.

When to use it

  • ✅ Investigating a p99 spike on a specific route
  • ✅ Proving "rule X is the slow one" before requesting a CRS upstream change
  • ✅ Short, targeted windows (minutes, not hours)
  • ❌ Always-on production dashboards — use TransactionEvent.PhaseDuration + RulesEvaluated instead

Operator use cases → events

Operator question Signal built from
Am I under attack right now? TransactionEvent{Kind=finish, Interruption!=nil}requests_total{decision="blocked"} counter
How many real blocks in the last 5 minutes? RuleMatchEvent{Action="deny"|"drop"|"redirect", Enforced=true} → per-minute rate
Which rules actually do work? RuleMatchEvent aggregated by Rule.ID() → top-N over time
Did my rule change double the block rate? Same signal, compared against baseline window
Is my DetectionOnly ruleset too noisy to enforce? RuleMatchEvent{Action="deny", Enforced=false} → would-have-blocked rate
Why is p99 up? TransactionEvent{Kind=phase_end, PhaseDuration, RulesEvaluated} → p99 histogram per phase
Which rule is the slow one? Opt in per-rule timing (section above), then RuleTimingEvent.Duration per Rule.ID()
Is the engine healthy? EngineEvent stream grouped by Kind
Is SecRequestBodyLimit too low? EngineEvent{Kind=body_truncated} counter; spike = raise limit
What ruleset is this node running? EngineReadyEvent{RulesLoaded, InitDuration} → info gauge
Cold-start budget on serverless / edge? EngineReadyEvent.InitDuration on each instance boot
Is Coraza N ms of my tail? Pass request context.Context through NewTransactionWithOptions; OTel sink starts coraza.evaluate span chained to parent trace
Config reload regression? Alert on EngineReadyEvent.RulesLoaded delta after deploys

Example — wiring into coraza-caddy

To expose these events as Prometheus counters on Caddy's existing /metrics endpoint, a Caddy module attaches a sink in its buildWAF:

import (
    "github.com/corazawaf/coraza/v3"
    "github.com/corazawaf/coraza/v3/experimental"
    "github.com/prometheus/client_golang/prometheus/promauto"
)

var wafRequests = promauto.NewCounterVec(
    prometheus.CounterOpts{Namespace: "caddy", Subsystem: "waf", Name: "requests_total"},
    []string{"decision"},
)

type caddySink struct{}
func (*caddySink) OnEngineReady(plugintypes.EngineReadyEvent) {}
func (*caddySink) OnRuleMatch(plugintypes.RuleMatchEvent)     {}
func (*caddySink) OnEngineError(plugintypes.EngineEvent)      {}
func (*caddySink) OnTransaction(ev plugintypes.TransactionEvent) {
    if ev.Kind == plugintypes.TransactionEventFinish {
        decision := "allowed"
        if ev.Interruption != nil { decision = "blocked" }
        wafRequests.WithLabelValues(decision).Inc()
    }
}

// In the Caddy module's buildWAF():
cfg = experimental.WAFConfigWithTelemetrySink(cfg, &caddySink{})

No new scrape target, no new registry — the series appear on Caddy's built-in /metrics endpoint as caddy_waf_requests_total. Operators add rate(caddy_waf_requests_total{decision="blocked"}[5m]) to their existing dashboard. Same pattern works for Envoy stats (proxy-wasm), HAProxy SPOP (spoa), zap, slog, OpenTelemetry — each connector maps events to its native telemetry primitives.

What's in

Public surface (all under experimental/ or additive on existing types)

  • experimental/plugins/plugintypes.TelemetrySink — 4-method interface: OnEngineReady, OnTransaction, OnRuleMatch, OnEngineError
  • experimental/plugins/plugintypes.RuleTimingSink — optional extension interface for opt-in per-rule timing
  • Event structs: TransactionEvent, RuleMatchEvent, EngineEvent, EngineReadyEvent, RuleTimingEvent
  • Experimental shims and helpers:
    • experimental.WAFConfigWithTelemetrySink(cfg, sink)
    • experimental.WAFConfigWithPerRuleTiming(cfg, bool)
    • experimental.WAFEnablePerRuleTiming(waf, bool) — runtime toggle
    • experimental.NoopTelemetrySink() — explicit no-op for callers that prefer a non-nil value (the engine treats nil as no-op for free)
  • types.RulePhase.String() — stable label value for metrics

Internal changes

  • internal/corazawaf.WAF gains TelemetrySink, cached ruleTimingSink (optional extension), and perRuleTimingEnabled atomic.Bool
  • Emit calls at 6 existing seams: transaction start, each phase end, rule match, transaction finish, body-processor error, body-limit truncation
  • Per-rule timing path in RuleGroup.Eval gated on an atomic.Bool; off-state cost is one atomic load per phase

No breaking changes

  • coraza.WAFConfig public interface: unchanged
  • coraza.WAF public interface: unchanged
  • types.RulePhase.String() is additive — the codebase's internal fmt calls all use %d, so format output for existing callers is unaffected
  • All additions behind the experimental shim pattern (mirrors WAFConfigWithRuleObserver), so iteration before v4 won't break third-party WAFConfig implementers

TinyGo

Validated with tinygo build -target=wasip1. 953 KB wasm, clean build, no new imports outside stdlib in core.

Security posture

Documented operator responsibilities (same character as the existing ErrorLogCb contract):

  • Cardinality: RuleMatchEvent.Data values are attacker-controlled. Sinks MUST NOT use them as metric labels or span attributes — doing so is a cardinality-bomb DoS on the metrics backend. Use them only in structured logs, with redaction and truncation.
  • Non-blocking: Sink callbacks run synchronously on the request hot path. Buffer to a channel if heavy work is needed.
  • No panics: A panicking sink crashes the request, same as a panicking ErrorLogCb.

No detection-evasion path: emits are unconditional at every seam. Sink absence/errors do not mask rule matches. Sink configuration is immutable post-NewWAF except for the per-rule timing atomic flag, which is explicitly concurrent-safe.

Tests

  • 18 integration tests in experimental/telemetry_sink_test.go exercising every emit seam, Action/Enforced semantics, DetectionOnly mode, body-truncation, runtime toggle, sink-without-extension graceful degradation, nil sink, context propagation, EngineReady, and the third-party WAFConfig/WAF fallback paths
  • 5 benchmarks (baseline, noop, counting, per-rule-off, per-rule-on) in experimental/telemetry_sink_bench_test.go
  • Race detector green across full 25-package sweep, on default build and coraza.rule.multiphase_evaluation
  • Coverage: patch 99.41%, project 87.66% (+0.27%) per codecov

Opportunity for future metrics: OWASP CRS awareness

A small future subpackage — e.g. coraza/v3/telemetry/crs — could map CRS rule id ranges to category labels (sqli, xss, rce, …) and read tx.anomaly_score_* variables. Sinks opt in by importing; users not on CRS pay nothing. Mentioning this only as a possibility so reviewers understand why the engine stays CRS-agnostic in this PR. Not proposing to ship it.

Open questions for reviewers

  1. Is Action string + Enforced bool on RuleMatchEvent the right split? The existing MatchedRule.Disruptive() conflates "has a disruptive-type action slot" with "will enforce it." I split them because operators asking "how many blocks?" want Action="deny" && Enforced=true, not a boolean that includes pass. Worth pushback.

  2. Should RuleTimingSink be a separate interface or a 5th method on TelemetrySink? Chose separate extension so existing sinks stay 4-method and don't force every sink to implement a high-cardinality hook. Engine caches the type assertion at SetTelemetrySink time so runtime cost is one pointer compare.

  3. types.RulePhase.String() — fine or controversial? Added for metric label stability. Internal callers all use %d so no behavior change for them. Any external consumer relying on %v output would see "request_headers" instead of "1".

  4. EngineReadyEvent fields — enough, too much, wrong? Currently RulesLoaded + InitDuration. A RulesetHash would also be useful but means sha-ing all loaded SecLang sources at startup. Skipped pending a reviewer opinion on whether that cost is justified.

  5. Should WithTelemetrySink and WithPerRuleTiming move onto the public coraza.WAFConfig interface in v4? Kept them private for now so third-party WAFConfig implementations aren't forced to support telemetry.

What this PR explicitly does not include

  • No built-in Prometheus, OpenTelemetry, or log-line sink. Core stays implementation-free.
  • No connector changes. Connector integrations (Caddy, proxy-wasm, SPOA) are separate work.
  • No redaction helpers, no anomaly-score reader, no CRS categorization helper.
  • No Grafana dashboard.

Claude Code (Opus 4.7) was used in the development of this change.

Summary by CodeRabbit

Release Notes

  • New Features
    • Added telemetry system capturing transaction lifecycle events, rule matches, and engine errors for WAF monitoring integration
    • Added per-rule timing capability to track individual rule execution duration
    • Introduced APIs to attach custom telemetry sinks and enable per-rule timing configuration

@coderabbitai

coderabbitai Bot commented Apr 24, 2026

Copy link
Copy Markdown
Contributor

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: d3e398ac-c66b-438b-947b-d0c9c01489e0

📥 Commits

Reviewing files that changed from the base of the PR and between 8b28b72 and fdc4820.

📒 Files selected for processing (11)
  • config.go
  • experimental/plugins/plugintypes/telemetry.go
  • experimental/telemetry_sink.go
  • experimental/telemetry_sink_bench_test.go
  • experimental/telemetry_sink_test.go
  • internal/corazawaf/rulegroup.go
  • internal/corazawaf/telemetry.go
  • internal/corazawaf/transaction.go
  • internal/corazawaf/waf.go
  • types/phase.go
  • waf.go

📝 Walkthrough

Walkthrough

The PR introduces an experimental telemetry API for Coraza WAF observability, including event kind enums, event structs carrying transaction and rule data, telemetry sink interfaces, and internal wiring to emit events throughout WAF initialization and transaction lifecycle phases.

Changes

Cohort / File(s) Summary
Telemetry API Types
experimental/plugins/plugintypes/telemetry.go
Defines bounded event enums (TransactionEventKind, EngineEventKind) with String() methods, event structs (TransactionEvent, RuleMatchEvent, EngineEvent, RuleTimingEvent, EngineReadyEvent), and sink interfaces (TelemetrySink and optional RuleTimingSink).
Experimental Integration Layer
config.go, experimental/telemetry_sink.go
Adds fluent config methods (WithTelemetrySink, WithPerRuleTiming) and experimental helpers (WAFConfigWithTelemetrySink, WAFConfigWithPerRuleTiming, WAFEnablePerRuleTiming, NoopTelemetrySink) using capability-safe type assertions.
Experimental Tests & Benchmarks
experimental/telemetry_sink_test.go, experimental/telemetry_sink_bench_test.go
Comprehensive tests validate telemetry correctness (EngineReady, transaction lifecycle, rule matches, engine errors, per-rule timing) with helper fixtures; benchmarks measure overhead across sink configurations.
Core WAF Telemetry Implementation
internal/corazawaf/waf.go, internal/corazawaf/telemetry.go, internal/corazawaf/transaction.go, internal/corazawaf/rulegroup.go, waf.go, types/phase.go
Integrates telemetry sink storage and runtime toggle into WAF; adds transaction-scoped emitters for lifecycle and rule events; enables conditional per-rule timing in rule evaluation; records initialization duration; adds phase String() helper; exposes per-rule timing control via wrapper.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title accurately summarizes the main change: adding a pluggable TelemetrySink interface for observability in the experimental package.
Docstring Coverage ✅ Passed Docstring coverage is 82.61% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

Warning

Review ran into problems

🔥 Problems

Timed out fetching pipeline failures after 30000ms


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
Review rate limit: 7/8 reviews remaining, refill in 7 minutes and 30 seconds.

Comment @coderabbitai help to get the list of available commands and usage tips.

@codecov

codecov Bot commented Apr 24, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.41860% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 87.66%. Comparing base (8b28b72) to head (fdc4820).
⚠️ Report is 7 commits behind head on main.

Files with missing lines Patch % Lines
internal/corazawaf/transaction.go 85.71% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1616      +/-   ##
==========================================
+ Coverage   87.39%   87.66%   +0.27%     
==========================================
  Files         178      181       +3     
  Lines        9147     9317     +170     
==========================================
+ Hits         7994     8168     +174     
+ Misses        879      876       -3     
+ Partials      274      273       -1     
Flag Coverage Δ
coraza.no_memoize 87.75% <99.41%> (+0.27%) ⬆️
coraza.rule.case_sensitive_args_keys 87.63% <99.41%> (+0.27%) ⬆️
coraza.rule.mandatory_rule_id_check 87.65% <99.41%> (+0.27%) ⬆️
coraza.rule.multiphase_evaluation 87.43% <99.41%> (+0.27%) ⬆️
coraza.rule.no_regex_multiline 87.62% <99.41%> (+0.27%) ⬆️
coraza.rule.rx_prefilter 87.66% <99.41%> (+0.27%) ⬆️
default 87.66% <99.41%> (+0.27%) ⬆️
examples+ 16.18% <13.72%> (-0.07%) ⬇️
examples+coraza.no_memoize 85.73% <99.41%> (+0.31%) ⬆️
examples+coraza.rule.case_sensitive_args_keys 85.71% <99.41%> (+0.30%) ⬆️
examples+coraza.rule.mandatory_rule_id_check 85.82% <99.41%> (+0.30%) ⬆️
examples+coraza.rule.multiphase_evaluation 87.43% <99.41%> (+0.27%) ⬆️
examples+coraza.rule.no_regex_multiline 85.65% <99.41%> (+0.31%) ⬆️
examples+coraza.rule.rx_prefilter 85.92% <99.41%> (+0.30%) ⬆️
examples+no_fs_access 85.07% <99.41%> (+0.32%) ⬆️
ftw 87.66% <99.41%> (+0.27%) ⬆️
no_fs_access 87.02% <99.41%> (+0.28%) ⬆️
tinygo 87.66% <99.41%> (+0.27%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@jptosso jptosso force-pushed the feat/telemetry-sink branch from a6b746a to dd1005a Compare April 24, 2026 21:02

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🧹 Nitpick comments (4)
internal/corazawaf/transaction.go (1)

910-918: Minor: shared helper makes truncation events ambiguous for sinks.

setAndReturnBodyLimitInterruption is invoked for both request-body (HTTP 413) and response-body (HTTP 500) truncation. The emitted message is a single static string, so sinks can distinguish only via tx.lastPhase. Consider passing the direction/status into the message (or a dedicated event field) so operators get actionable context without having to infer phase:

-	tx.emitEngineError(plugintypes.EngineEventBodyTruncated, nil, "body exceeded configured limit")
+	tx.emitEngineError(plugintypes.EngineEventBodyTruncated, nil,
+		fmt.Sprintf("body exceeded configured limit (status %d)", status))
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/corazawaf/transaction.go` around lines 910 - 918, The helper
setAndReturnBodyLimitInterruption currently emits a static engine event that is
ambiguous for request vs response truncation; update it to include
direction/status context in the emitted event by adding the HTTP direction or
status into the emitEngineError call (e.g., include status and a computed
direction string based on the status or tx.lastPhase), or add a dedicated field
to the emitted payload so sinks can distinguish request-body (413) vs
response-body (500) truncation; change references in
setAndReturnBodyLimitInterruption, tx.emitEngineError, and the created
types.Interruption to carry that extra context.
telemetry/doc.go (1)

4-32: Consider documenting the sink execution contract (synchronous, must not panic/block).

The doc covers where sinks live but not how they're called. Since every sink method runs on the request-processing goroutine, an implementation that blocks (e.g., a synchronous HTTP POST) or panics will stall or corrupt a transaction. Worth adding a short "Contract" section:

  • Sinks are called synchronously on the request goroutine.
  • Sinks must not panic. Recover internally if necessary.
  • Long-running work (network I/O, disk writes) should be dispatched to a bounded worker pool owned by the sink; do not block inside the callback.
  • Matched data may contain PII; redact/truncate before export.

This aligns with the guideline "Document thread-safety guarantees" and protects users from the transaction-pool-poisoning failure mode flagged on transaction.go:1672.

As per coding guidelines: "Document thread-safety guarantees".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@telemetry/doc.go` around lines 4 - 32, Add a short "Contract" section to the
telemetry package docs stating that TelemetrySink implementations (e.g., Noop
and any types implementing plugintypes.TelemetrySink) are invoked synchronously
on the request-processing goroutine, must not panic (recover internally), must
not perform blocking or long-running work directly (dispatch such work to a
bounded worker pool or background goroutine), and should redact/truncate any
potentially sensitive/matched data before exporting; include a one-line note
that violating this contract can stall or corrupt transactions (see transaction
handling paths that call sinks).
internal/corazawaf/rulegroup.go (1)

163-166: LGTM — clean hot-path gating.

A few observations confirming correctness:

  • The gate perRuleTimingEnabled.Load() && ruleTimingSink != nil short-circuits: a nil timing sink never pays the atomic load cost due to ordering — consider swapping the order for a tiny micro-optimization in the nil-sink case (ruleTimingSink != nil && perRuleTimingEnabled.Load()), since the pointer compare is cheaper than an atomic load on some architectures. Minor / optional.
  • len(tx.matchedRules) > matchCountBefore correctly reflects chain-rule semantics since MatchRule appends only on full-chain match.
  • emitPhaseEnd unconditionally called with an internal nil-check is fine; the function-call overhead is already accounted for in the +29.8 ns/op benchmark.

Also applies to: 265-275, 292-294

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/corazawaf/rulegroup.go` around lines 163 - 166, The per-rule timing
gate currently does an atomic load before checking the sink pointer
(perRuleTimingEnabled.Load() && tx.WAF.ruleTimingSink != nil); swap the operands
to check the pointer first (tx.WAF.ruleTimingSink != nil &&
tx.WAF.perRuleTimingEnabled.Load()) to avoid the atomic load when the sink is
nil; apply the same reordering to the other occurrences flagged (the checks
around lines 265-275 and 292-294), leaving semantics unchanged for functions
like MatchRule, the perRuleTiming variable, and emitPhaseEnd.
experimental/telemetry_sink_test.go (1)

285-312: Nit: TestTelemetrySink_NilShimReturnsInputConfig name is misleading.

The test doesn't pass a nil config — it passes a non-wafConfig implementation and asserts the shim returns it unchanged. A name like TestTelemetrySink_UnsupportedConfig_ReturnsInputUnchanged would better describe what's being verified (and matches the companion test at line 300 PerRuleTimingShims_Fallback). Purely cosmetic.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@experimental/telemetry_sink_test.go` around lines 285 - 312, Rename the
misleading test TestTelemetrySink_NilShimReturnsInputConfig to
TestTelemetrySink_UnsupportedConfig_ReturnsInputUnchanged and update any
references, so the name accurately reflects that it passes a non-wafConfig
implementation and expects the shim (experimental.WAFConfigWithTelemetrySink) to
return the input unchanged; keep the test body unchanged and mirror the naming
style used by the companion test TestTelemetrySink_PerRuleTimingShims_Fallback.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@experimental/telemetry_sink_test.go`:
- Line 501: The call to tx.ProcessRequestBody() ignores its returned error
causing golangci-lint errcheck failure; update the test to capture and handle
the error from tx.ProcessRequestBody() (e.g., err := tx.ProcessRequestBody())
and assert or fail on non-nil error (use t.Fatalf/t.Fatalf-like helper or
require.NoError/AssertNoError depending on test helpers in use) so the error is
explicitly checked and reported.

In `@internal/corazawaf/telemetry.go`:
- Around line 20-27: SetTelemetrySink currently writes w.TelemetrySink and
w.ruleTimingSink without synchronization while
emitTransactionStart/emitRuleMatch/emitRuleTimed read them concurrently,
creating a data race; fix by documenting the thread-safety guarantee: add a
clear doc comment on the WAF.SetTelemetrySink method stating it MUST be called
only at startup (before the WAF is published to any transaction) and is not safe
to call concurrently with active transactions—ensure this matches how NewWAF
wires the sink and mention that perRuleTimingEnabled remains the
runtime-toggleable knob; alternatively, if runtime reconfiguration is desired
later, replace those fields with
atomic.Pointer[plugintypes.TelemetrySink]/atomic.Pointer[plugintypes.RuleTimingSink]
and have the emit helpers call Load() once.
- Around line 141-142: The file ends with extra blank line(s) after the final
closing brace '}', which causes gofmt/golangci-lint failures; remove the
trailing blank lines so the file terminates with a single newline immediately
following the last '}' and then run gofmt -w on the file (or go fmt ./...) and
commit the formatted file to resolve the CI lint error.

In `@internal/corazawaf/transaction.go`:
- Around line 1671-1674: The Close method currently calls
emitTransactionFinish() before cleanup which allows a panic in
sink.OnTransaction to skip tx.variables.reset(), tx.requestBodyBuffer.Reset(),
tx.responseBodyBuffer.Reset() and poison tx.WAF.txPool.Put(tx); fix by either
moving the call to emitTransactionFinish() to after all cleanup/reset calls in
Transaction.Close (so cleanup always runs before txPool.Put) or wrap
emitTransactionFinish() with a panic-safe defer recover() inside
emitTransactionFinish itself (telemetry sink isolation) so a sink panic is
logged but does not abort cleanup; also apply the same panic-guard pattern to
other emit* call sites (rule match, engine error, phase end) if you choose the
recover approach.

In `@internal/corazawaf/waf.go`:
- Around line 136-149: Update the WAF struct doc to explicitly exempt
telemetry-related fields (TelemetrySink, ruleTimingSink, perRuleTimingEnabled)
from the “immutable fields” rule: state that these three fields are
intentionally runtime-mutable and explain that perRuleTimingEnabled is toggled
atomically via SetPerRuleTimingEnabled and that TelemetrySink must not be
assigned directly (use SetTelemetrySink so ruleTimingSink stays synchronized).
Also add a short note warning readers that direct writes to TelemetrySink or
ruleTimingSink can create race conditions and that SetTelemetrySink handles the
required consistency between those two fields.

In `@telemetry/noop_test.go`:
- Around line 13-20: Replace the redundant typed variable declaration in
TestNoopSatisfiesInterface with a regular short declaration for the runtime
calls and add a compile-time blank identifier assertion to show the interface
satisfaction: change the line "var s plugintypes.TelemetrySink =
telemetry.Noop()" to "s := telemetry.Noop()" and add "var _
plugintypes.TelemetrySink = telemetry.Noop()" (or the equivalent blank
assertion) so the test still calls s.OnEngineReady / s.OnTransaction /
s.OnRuleMatch / s.OnEngineError while avoiding staticcheck ST1023 and making the
interface satisfaction explicit.

In `@types/phase.go`:
- Around line 30-46: The test output changed because RulePhase now implements
fmt.Stringer via RulePhase.String(), so update the failing test assertion to
print or compare the numeric phase explicitly: in the test referencing m.Phase
(e.g., experimental/telemetry_sink_test.go test that does t.Fatalf("phase = %v",
m.Phase)), change the format to use %d with int(m.Phase) or otherwise convert
m.Phase to an int before formatting/comparing; this keeps the test output stable
while preserving RulePhase.String().

---

Nitpick comments:
In `@experimental/telemetry_sink_test.go`:
- Around line 285-312: Rename the misleading test
TestTelemetrySink_NilShimReturnsInputConfig to
TestTelemetrySink_UnsupportedConfig_ReturnsInputUnchanged and update any
references, so the name accurately reflects that it passes a non-wafConfig
implementation and expects the shim (experimental.WAFConfigWithTelemetrySink) to
return the input unchanged; keep the test body unchanged and mirror the naming
style used by the companion test TestTelemetrySink_PerRuleTimingShims_Fallback.

In `@internal/corazawaf/rulegroup.go`:
- Around line 163-166: The per-rule timing gate currently does an atomic load
before checking the sink pointer (perRuleTimingEnabled.Load() &&
tx.WAF.ruleTimingSink != nil); swap the operands to check the pointer first
(tx.WAF.ruleTimingSink != nil && tx.WAF.perRuleTimingEnabled.Load()) to avoid
the atomic load when the sink is nil; apply the same reordering to the other
occurrences flagged (the checks around lines 265-275 and 292-294), leaving
semantics unchanged for functions like MatchRule, the perRuleTiming variable,
and emitPhaseEnd.

In `@internal/corazawaf/transaction.go`:
- Around line 910-918: The helper setAndReturnBodyLimitInterruption currently
emits a static engine event that is ambiguous for request vs response
truncation; update it to include direction/status context in the emitted event
by adding the HTTP direction or status into the emitEngineError call (e.g.,
include status and a computed direction string based on the status or
tx.lastPhase), or add a dedicated field to the emitted payload so sinks can
distinguish request-body (413) vs response-body (500) truncation; change
references in setAndReturnBodyLimitInterruption, tx.emitEngineError, and the
created types.Interruption to carry that extra context.

In `@telemetry/doc.go`:
- Around line 4-32: Add a short "Contract" section to the telemetry package docs
stating that TelemetrySink implementations (e.g., Noop and any types
implementing plugintypes.TelemetrySink) are invoked synchronously on the
request-processing goroutine, must not panic (recover internally), must not
perform blocking or long-running work directly (dispatch such work to a bounded
worker pool or background goroutine), and should redact/truncate any potentially
sensitive/matched data before exporting; include a one-line note that violating
this contract can stall or corrupt transactions (see transaction handling paths
that call sinks).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a82c7ae9-6b53-4d3e-98e4-1e1d5ffd4c8f

📥 Commits

Reviewing files that changed from the base of the PR and between 8b28b72 and a6b746a.

📒 Files selected for processing (14)
  • config.go
  • experimental/plugins/plugintypes/telemetry.go
  • experimental/telemetry_sink.go
  • experimental/telemetry_sink_test.go
  • internal/corazawaf/rulegroup.go
  • internal/corazawaf/telemetry.go
  • internal/corazawaf/transaction.go
  • internal/corazawaf/waf.go
  • telemetry/bench_test.go
  • telemetry/doc.go
  • telemetry/noop.go
  • telemetry/noop_test.go
  • types/phase.go
  • waf.go

Comment thread experimental/telemetry_sink_test.go Outdated
Comment thread internal/corazawaf/telemetry.go
Comment thread internal/corazawaf/telemetry.go Outdated
Comment thread internal/corazawaf/transaction.go
Comment thread internal/corazawaf/waf.go
Comment thread telemetry/noop_test.go Outdated
Comment on lines +13 to +20
func TestNoopSatisfiesInterface(t *testing.T) {
var s plugintypes.TelemetrySink = telemetry.Noop()
// Methods must be safe to call with zero-value events.
s.OnEngineReady(plugintypes.EngineReadyEvent{})
s.OnTransaction(plugintypes.TransactionEvent{})
s.OnRuleMatch(plugintypes.RuleMatchEvent{})
s.OnEngineError(plugintypes.EngineEvent{})
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix staticcheck ST1023 and strengthen the interface assertion.

telemetry.Noop() already returns plugintypes.TelemetrySink, so the explicit type on the LHS is redundant (staticcheck ST1023, CI failure). A compile-time blank assertion expresses the "satisfies interface" intent more clearly than a runtime variable with an explicit type.

🔧 Proposed fix
 func TestNoopSatisfiesInterface(t *testing.T) {
-	var s plugintypes.TelemetrySink = telemetry.Noop()
+	// Compile-time check that Noop() satisfies the sink contract.
+	var _ plugintypes.TelemetrySink = telemetry.Noop()
+	s := telemetry.Noop()
 	// Methods must be safe to call with zero-value events.
 	s.OnEngineReady(plugintypes.EngineReadyEvent{})
 	s.OnTransaction(plugintypes.TransactionEvent{})
 	s.OnRuleMatch(plugintypes.RuleMatchEvent{})
 	s.OnEngineError(plugintypes.EngineEvent{})
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func TestNoopSatisfiesInterface(t *testing.T) {
var s plugintypes.TelemetrySink = telemetry.Noop()
// Methods must be safe to call with zero-value events.
s.OnEngineReady(plugintypes.EngineReadyEvent{})
s.OnTransaction(plugintypes.TransactionEvent{})
s.OnRuleMatch(plugintypes.RuleMatchEvent{})
s.OnEngineError(plugintypes.EngineEvent{})
}
func TestNoopSatisfiesInterface(t *testing.T) {
// Compile-time check that Noop() satisfies the sink contract.
var _ plugintypes.TelemetrySink = telemetry.Noop()
s := telemetry.Noop()
// Methods must be safe to call with zero-value events.
s.OnEngineReady(plugintypes.EngineReadyEvent{})
s.OnTransaction(plugintypes.TransactionEvent{})
s.OnRuleMatch(plugintypes.RuleMatchEvent{})
s.OnEngineError(plugintypes.EngineEvent{})
}
🧰 Tools
🪛 GitHub Check: lint

[failure] 14-14:
ST1023: should omit type plugintypes.TelemetrySink from declaration; it will be inferred from the right-hand side (staticcheck)

🪛 golangci-lint (2.11.4)

[error] 14-14: ST1023: should omit type plugintypes.TelemetrySink from declaration; it will be inferred from the right-hand side

(staticcheck)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@telemetry/noop_test.go` around lines 13 - 20, Replace the redundant typed
variable declaration in TestNoopSatisfiesInterface with a regular short
declaration for the runtime calls and add a compile-time blank identifier
assertion to show the interface satisfaction: change the line "var s
plugintypes.TelemetrySink = telemetry.Noop()" to "s := telemetry.Noop()" and add
"var _ plugintypes.TelemetrySink = telemetry.Noop()" (or the equivalent blank
assertion) so the test still calls s.OnEngineReady / s.OnTransaction /
s.OnRuleMatch / s.OnEngineError while avoiding staticcheck ST1023 and making the
interface satisfaction explicit.

Comment thread types/phase.go
@jptosso jptosso force-pushed the feat/telemetry-sink branch 3 times, most recently from c4a5398 to fe163f5 Compare April 24, 2026 21:19

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (5)
internal/corazawaf/waf.go (1)

136-149: ⚠️ Potential issue | 🟡 Minor

WAF struct doc still claims all fields are immutable, but these three are designed for runtime mutation.

Lines 43-51 still state "All WAF instance fields are immutable, if you update any of them in runtime you might create concurrency issues." That contradicts TelemetrySink/ruleTimingSink (mutated together via SetTelemetrySink) and perRuleTimingEnabled (toggled atomically via SetPerRuleTimingEnabled). Please carve out these three fields in the struct-level comment and add a warning on TelemetrySink that callers must not assign it directly (use SetTelemetrySink to keep ruleTimingSink in sync).

As per coding guidelines: "Document thread-safety guarantees".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/corazawaf/waf.go` around lines 136 - 149, Update the WAF struct
comment to document thread-safety exceptions: explicitly call out that
TelemetrySink, ruleTimingSink and perRuleTimingEnabled are mutable at runtime
and must not be modified directly; instruct callers to use SetTelemetrySink (so
TelemetrySink and ruleTimingSink remain in sync) and SetPerRuleTimingEnabled
(atomic toggle) instead, and add a short warning next to the TelemetrySink field
that direct assignment is forbidden.
telemetry/noop_test.go (1)

15-22: ⚠️ Potential issue | 🟡 Minor

CI lint (ST1023) still failing — redundant type on LHS.

telemetry.Noop() already returns plugintypes.TelemetrySink, so the explicit type here still trips staticcheck ST1023 and the GitHub Check: lint job. Switch to a blank-identifier compile-time assertion (which expresses the "satisfies interface" intent more strongly) plus an inferred short declaration for the runtime calls.

🔧 Proposed fix
 func TestNoopSatisfiesInterface(t *testing.T) {
-	var s plugintypes.TelemetrySink = telemetry.Noop()
+	// Compile-time check that Noop() satisfies the sink contract.
+	var _ plugintypes.TelemetrySink = telemetry.Noop()
+	s := telemetry.Noop()
 	// Methods must be safe to call with zero-value events.
 	s.OnEngineReady(plugintypes.EngineReadyEvent{})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@telemetry/noop_test.go` around lines 15 - 22, Replace the redundant explicit
type assertion in TestNoopSatisfiesInterface: remove "var s
plugintypes.TelemetrySink = telemetry.Noop()" and instead add a compile-time
interface satisfaction like "var _ plugintypes.TelemetrySink = telemetry.Noop()"
and use a short variable declaration for runtime calls (e.g., "s :=
telemetry.Noop()") so calls to s.OnEngineReady, s.OnTransaction, s.OnRuleMatch,
and s.OnEngineError remain valid while satisfying staticcheck ST1023.
internal/corazawaf/telemetry.go (2)

20-27: ⚠️ Potential issue | 🟠 Major

Thread-safety of SetTelemetrySink still undocumented — and the two field writes are not atomic.

SetTelemetrySink writes w.TelemetrySink and w.ruleTimingSink as plain interface fields, while emitTransactionStart/emitRuleMatch/emitRuleTimed/emitEngineError/emitPhaseEnd/emitTransactionFinish read them concurrently from transaction goroutines. Interface values are two-word fat pointers, so a call made on a live WAF can produce a torn read and crash — this is a data race per the Go memory model.

Two acceptable fixes (pick one):

  • Document that SetTelemetrySink is startup-only (must be called before the WAF is handed out to any caller producing transactions), matching how NewWAF wires the sink today; perRuleTimingEnabled remains the runtime-toggleable knob.
  • Or back both fields with atomic.Pointer[plugintypes.TelemetrySink] / atomic.Pointer[plugintypes.RuleTimingSink] and have the emit helpers Load() once.

As per coding guidelines: "Document thread-safety guarantees".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/corazawaf/telemetry.go` around lines 20 - 27, SetTelemetrySink
currently writes WAF.TelemetrySink and w.ruleTimingSink non-atomically while
emitTransactionStart / emitRuleMatch / emitRuleTimed / emitEngineError /
emitPhaseEnd / emitTransactionFinish read them concurrently; either document
that SetTelemetrySink is startup-only (callable only before handing WAF to any
goroutine, matching NewWAF wiring and keeping perRuleTimingEnabled as the
runtime toggle) or make the fields safe for concurrent access by replacing
TelemetrySink and ruleTimingSink with atomic.Pointer[plugintypes.TelemetrySink]
/ atomic.Pointer[plugintypes.RuleTimingSink] and update SetTelemetrySink to
Store() the pointers and all emit helpers to Load() once per emission; add a
one-line doc comment on SetTelemetrySink stating the chosen thread-safety
guarantee.

138-140: ⚠️ Potential issue | 🔴 Critical

CI still failing on gofmt — trailing blank line after the final }.

The lint pipeline continues to flag this file at line 139 (File is not properly formatted (gofmt)). Run gofmt -w internal/corazawaf/telemetry.go (or go fmt ./...) and commit. As per coding guidelines: "Use gofmt and golint for code formatting".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/corazawaf/telemetry.go` around lines 138 - 140, The file ends with
an extra trailing blank line after the final closing brace '}', causing gofmt to
fail; remove the blank line and reformat the code (run gofmt -w or go fmt ./...)
so the file is properly formatted, then commit the change.
internal/corazawaf/transaction.go (1)

1671-1674: ⚠️ Potential issue | 🟠 Major

Sink panic in emitTransactionFinish can still poison the txPool.

emitTransactionFinish() still runs synchronously before any cleanup in Close(). A panic from a user-supplied sink will unwind through the defer tx.WAF.txPool.Put(tx) but skip tx.variables.reset(), tx.requestBodyBuffer.Reset(), and tx.responseBodyBuffer.Reset() (Lines 1695-1701), handing a dirty Transaction back to the pool (stale buffers, undeleted temp files, inconsistent collections).

Either move the emit to after cleanup, or wrap the helper with defer recover() centrally in telemetry.go.

🛡️ Option A — emit after cleanup
 func (tx *Transaction) Close() error {
-	tx.emitTransactionFinish()
 	defer tx.WAF.txPool.Put(tx)
+	defer tx.emitTransactionFinish()

 	var errs []error
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/corazawaf/transaction.go` around lines 1671 - 1674, The call to
emitTransactionFinish() in Transaction.Close() can panic and bypass cleanup,
returning a poisoned Transaction to txPool; to fix, ensure emitTransactionFinish
cannot skip cleanup by either moving the emitTransactionFinish() invocation to
after all cleanup steps (variables.reset(), requestBodyBuffer.Reset(),
responseBodyBuffer.Reset(), temp file removals) in Transaction.Close(), or wrap
the emitTransactionFinish call with a recover-protected helper in telemetry.go
(e.g., a safeEmitTransactionFinish wrapper that defers a recover and logs the
panic) and call that wrapper instead; reference the Transaction.Close method and
emitTransactionFinish function when making the change.
🧹 Nitpick comments (1)
experimental/telemetry_sink.go (1)

1-87: LGTM — clean capability-interface pattern, well-scoped experimental API.

Private capability interfaces (wafConfigWithTelemetrySink, wafConfigWithPerRuleTiming, wafWithPerRuleTimingToggle) cleanly keep the telemetry contract off the stable coraza.WAFConfig/coraza.WAF surface while still letting external callers wire it up. The doc comments explicitly mark each helper as experimental and may-change-before-v4, and WAFEnablePerRuleTiming correctly returns bool so callers can detect third-party WAFs that don't implement the toggle.

One minor consistency nit: WAFConfigWithTelemetrySink/WAFConfigWithPerRuleTiming silently return the input on a missing capability while WAFEnablePerRuleTiming returns false. The silent path is documented, but if an embedder passes a mock/third-party WAFConfig they'd get zero feedback that telemetry was dropped. Consider adding a similar boolean signal on the two config helpers in a follow-up, or document the trade-off more explicitly. Not a blocker for this PR.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@experimental/telemetry_sink.go` around lines 1 - 87, Add non-breaking
boolean-feedback helpers to surface when a capability was honoured: implement
WAFConfigWithTelemetrySinkOK(cfg coraza.WAFConfig, sink
plugintypes.TelemetrySink) (coraza.WAFConfig, bool) and
WAFConfigWithPerRuleTimingOK(cfg coraza.WAFConfig, enabled bool)
(coraza.WAFConfig, bool) that mirror the current WAFConfigWithTelemetrySink and
WAFConfigWithPerRuleTiming logic but return true when the cfg supports the
corresponding private interfaces (wafConfigWithTelemetrySink,
wafConfigWithPerRuleTiming) and false otherwise; update the doc comments for
these new functions to mention they provide explicit feedback without changing
the existing helpers.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@internal/corazawaf/telemetry.go`:
- Around line 20-27: SetTelemetrySink currently writes WAF.TelemetrySink and
w.ruleTimingSink non-atomically while emitTransactionStart / emitRuleMatch /
emitRuleTimed / emitEngineError / emitPhaseEnd / emitTransactionFinish read them
concurrently; either document that SetTelemetrySink is startup-only (callable
only before handing WAF to any goroutine, matching NewWAF wiring and keeping
perRuleTimingEnabled as the runtime toggle) or make the fields safe for
concurrent access by replacing TelemetrySink and ruleTimingSink with
atomic.Pointer[plugintypes.TelemetrySink] /
atomic.Pointer[plugintypes.RuleTimingSink] and update SetTelemetrySink to
Store() the pointers and all emit helpers to Load() once per emission; add a
one-line doc comment on SetTelemetrySink stating the chosen thread-safety
guarantee.
- Around line 138-140: The file ends with an extra trailing blank line after the
final closing brace '}', causing gofmt to fail; remove the blank line and
reformat the code (run gofmt -w or go fmt ./...) so the file is properly
formatted, then commit the change.

In `@internal/corazawaf/transaction.go`:
- Around line 1671-1674: The call to emitTransactionFinish() in
Transaction.Close() can panic and bypass cleanup, returning a poisoned
Transaction to txPool; to fix, ensure emitTransactionFinish cannot skip cleanup
by either moving the emitTransactionFinish() invocation to after all cleanup
steps (variables.reset(), requestBodyBuffer.Reset(), responseBodyBuffer.Reset(),
temp file removals) in Transaction.Close(), or wrap the emitTransactionFinish
call with a recover-protected helper in telemetry.go (e.g., a
safeEmitTransactionFinish wrapper that defers a recover and logs the panic) and
call that wrapper instead; reference the Transaction.Close method and
emitTransactionFinish function when making the change.

In `@internal/corazawaf/waf.go`:
- Around line 136-149: Update the WAF struct comment to document thread-safety
exceptions: explicitly call out that TelemetrySink, ruleTimingSink and
perRuleTimingEnabled are mutable at runtime and must not be modified directly;
instruct callers to use SetTelemetrySink (so TelemetrySink and ruleTimingSink
remain in sync) and SetPerRuleTimingEnabled (atomic toggle) instead, and add a
short warning next to the TelemetrySink field that direct assignment is
forbidden.

In `@telemetry/noop_test.go`:
- Around line 15-22: Replace the redundant explicit type assertion in
TestNoopSatisfiesInterface: remove "var s plugintypes.TelemetrySink =
telemetry.Noop()" and instead add a compile-time interface satisfaction like
"var _ plugintypes.TelemetrySink = telemetry.Noop()" and use a short variable
declaration for runtime calls (e.g., "s := telemetry.Noop()") so calls to
s.OnEngineReady, s.OnTransaction, s.OnRuleMatch, and s.OnEngineError remain
valid while satisfying staticcheck ST1023.

---

Nitpick comments:
In `@experimental/telemetry_sink.go`:
- Around line 1-87: Add non-breaking boolean-feedback helpers to surface when a
capability was honoured: implement WAFConfigWithTelemetrySinkOK(cfg
coraza.WAFConfig, sink plugintypes.TelemetrySink) (coraza.WAFConfig, bool) and
WAFConfigWithPerRuleTimingOK(cfg coraza.WAFConfig, enabled bool)
(coraza.WAFConfig, bool) that mirror the current WAFConfigWithTelemetrySink and
WAFConfigWithPerRuleTiming logic but return true when the cfg supports the
corresponding private interfaces (wafConfigWithTelemetrySink,
wafConfigWithPerRuleTiming) and false otherwise; update the doc comments for
these new functions to mention they provide explicit feedback without changing
the existing helpers.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: d5b2cab4-6e54-4e03-8e36-298bd281623e

📥 Commits

Reviewing files that changed from the base of the PR and between a6b746a and efff0ab.

📒 Files selected for processing (14)
  • config.go
  • experimental/plugins/plugintypes/telemetry.go
  • experimental/telemetry_sink.go
  • experimental/telemetry_sink_test.go
  • internal/corazawaf/rulegroup.go
  • internal/corazawaf/telemetry.go
  • internal/corazawaf/transaction.go
  • internal/corazawaf/waf.go
  • telemetry/bench_test.go
  • telemetry/doc.go
  • telemetry/noop.go
  • telemetry/noop_test.go
  • types/phase.go
  • waf.go
✅ Files skipped from review due to trivial changes (1)
  • telemetry/doc.go
🚧 Files skipped from review as they are similar to previous changes (5)
  • types/phase.go
  • telemetry/noop.go
  • waf.go
  • experimental/plugins/plugintypes/telemetry.go
  • experimental/telemetry_sink_test.go

@jptosso jptosso force-pushed the feat/telemetry-sink branch from fe163f5 to 0cda0a4 Compare April 24, 2026 21:22
Adds an opt-in TelemetrySink interface so connectors (coraza-caddy,
coraza-proxy-wasm, coraza-spoa, libcoraza) can route engine events into
their native observability stacks. Core ships the hooks only; no
Prometheus, OpenTelemetry, or log-schema dependency is added to go.mod.

Public surface (under experimental/):
  - plugintypes.TelemetrySink (4-method interface)
  - plugintypes.RuleTimingSink (optional extension for per-rule timing)
  - TransactionEvent, RuleMatchEvent, EngineEvent, EngineReadyEvent,
    RuleTimingEvent
  - experimental.WAFConfigWithTelemetrySink(cfg, sink)
  - experimental.WAFConfigWithPerRuleTiming(cfg, bool)
  - experimental.WAFEnablePerRuleTiming(waf, bool) runtime toggle
  - telemetry.Noop() explicit no-op sink
  - types.RulePhase.String() stable label value

Engine wiring is at six existing seams (transaction start, phase end,
rule match, transaction finish, body-processor error, body-limit
truncation). Per-rule timing lives behind an atomic.Bool gate so the
off-state cost is one atomic load per phase.

Performance for non-opt-in users (measured against upstream main,
benchtime=5s, 10 runs, Apple M4):
  main      : 3241.6 ns/op, 74 allocs
  this PR   : 3271.4 ns/op, 74 allocs
  delta     : +29.8 ns/op (+0.92%), 0 extra allocs

The ~30ns delta is six nil-pointer checks plus one atomic load per
phase. No heap allocations, no behavior change.

Full suite: 26/26 packages pass with -race. 100% coverage on every
experimental shim and every engine emit helper. TinyGo wasip1 build
verified.

Claude Code Opus 4.7 was used in the development of this change.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds an opt-in, dependency-free telemetry hook for Coraza engine events (transactions, rule matches, engine errors, and optional per-rule timings) via an experimental/plugins/plugintypes.TelemetrySink interface, enabling downstream connectors to export metrics/traces/logs in their native observability stacks.

Changes:

  • Wire a pluggable TelemetrySink into WAF construction and transaction/rule evaluation paths, emitting structured engine events.
  • Add opt-in per-rule timing with a runtime-toggleable atomic.Bool gate and an optional RuleTimingSink extension interface.
  • Add stable string representations for types.RulePhase for safer metric labeling, plus comprehensive tests/benchmarks for the telemetry surface.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
waf.go Attaches configured telemetry sink / per-rule timing at WAF init; emits EngineReady event.
config.go Adds internal config fields + private WithTelemetrySink/WithPerRuleTiming setters.
types/phase.go Adds RulePhase.String() for stable label/log identifiers.
internal/corazawaf/waf.go Adds telemetry fields + per-rule timing flag; emits transaction start.
internal/corazawaf/transaction.go Emits rule-match events, body processor errors, and body truncation engine events; emits transaction finish on Close().
internal/corazawaf/rulegroup.go Emits per-phase end transaction events and optional per-rule timing events.
internal/corazawaf/telemetry.go Centralizes sink attachment and telemetry emission helpers.
experimental/telemetry_sink.go Adds experimental shims for wiring sinks and toggling per-rule timing + noop sink.
experimental/plugins/plugintypes/telemetry.go Defines sink interfaces and event structs/enums (public experimental contract).
experimental/telemetry_sink_test.go Integration tests covering emit seams, semantics, and shims.
experimental/telemetry_sink_bench_test.go Benchmarks for baseline/noop/counting/per-rule on-off overhead.

Comment on lines +266 to +270
ruleStart := time.Now().UnixNano()
matchCountBefore := len(tx.matchedRules)
r.Evaluate(phase, tx, transformationCache)
tx.emitRuleTimed(r,
time.Duration(time.Now().UnixNano()-ruleStart),

Copilot AI Apr 30, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per-rule timing and phase timing are computed using time.Now().UnixNano() deltas. UnixNano strips Go's monotonic clock reading, so NTP/clock adjustments can produce negative or inaccurate durations that will be exported to telemetry sinks. Prefer capturing a time.Time start (time.Now()) and using time.Since(start) / time.Now().Sub(start) for both rule timing and phase duration emission so the monotonic component is preserved.

Suggested change
ruleStart := time.Now().UnixNano()
matchCountBefore := len(tx.matchedRules)
r.Evaluate(phase, tx, transformationCache)
tx.emitRuleTimed(r,
time.Duration(time.Now().UnixNano()-ruleStart),
ruleStart := time.Now()
matchCountBefore := len(tx.matchedRules)
r.Evaluate(phase, tx, transformationCache)
tx.emitRuleTimed(r,
time.Since(ruleStart),

Copilot uses AI. Check for mistakes.
Status: status,
Action: "deny",
}
tx.emitEngineError(plugintypes.EngineEventBodyTruncated, nil, "body exceeded configured limit")

Copilot AI Apr 30, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The emitted EngineEventBodyTruncated message is always "body exceeded configured limit", but the same helper is used for both request-body (413) and response-body (500) truncation. This makes the telemetry hard to action because operators can’t tell which limit to tune. Consider including whether it was the request or response body in the message (or pass that info into the helper) so sinks can expose clearer alerts/logs.

Suggested change
tx.emitEngineError(plugintypes.EngineEventBodyTruncated, nil, "body exceeded configured limit")
message := "body exceeded configured limit"
switch status {
case 413:
message = "request body exceeded configured limit"
case 500:
message = "response body exceeded configured limit"
}
tx.emitEngineError(plugintypes.EngineEventBodyTruncated, nil, message)

Copilot uses AI. Check for mistakes.
Comment on lines +673 to +688
// TestTelemetrySink_ContextPropagation: if the caller creates a transaction
// with a specific context, that context must surface in every tx event. This
// is the APM span-chaining contract.
func TestTelemetrySink_ContextPropagation(t *testing.T) {
sink := &recordingSink{}
cfg := coraza.NewWAFConfig().WithDirectives(`SecRuleEngine On`)
cfg = experimental.WAFConfigWithTelemetrySink(cfg, sink)
waf, err := coraza.NewWAF(cfg)
if err != nil {
t.Fatalf("new waf: %v", err)
}
tx := waf.NewTransactionWithID("my-request-id")
tx.ProcessURI("/", "GET", "HTTP/1.1")
tx.ProcessRequestHeaders()
tx.ProcessLogging()
_ = tx.Close()

Copilot AI Apr 30, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TestTelemetrySink_ContextPropagation doesn’t actually set a custom context (it uses NewTransactionWithID, which only changes the transaction ID). As written it only asserts TransactionID propagation, so it won’t catch regressions where a non-default context isn’t carried into events. Either set a non-background context via NewTransactionWithOptions (and assert it is the same in events) or rename the test to reflect what it verifies.

Copilot uses AI. Check for mistakes.
@fzipi

fzipi commented Apr 30, 2026

Copy link
Copy Markdown
Member

@coderabbitai full_review

@coderabbitai

coderabbitai Bot commented Apr 30, 2026

Copy link
Copy Markdown
Contributor

@fzipi: Sure! I'll redo the entire review of the PR now.

✅ Actions performed

Full review triggered.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants