Skip to content

feature: support custom interceptors#143

Merged
rustatian merged 5 commits intomasterfrom
feature/custom-interceptors
Feb 18, 2026
Merged

feature: support custom interceptors#143
rustatian merged 5 commits intomasterfrom
feature/custom-interceptors

Conversation

@rustatian
Copy link
Member

@rustatian rustatian commented Feb 18, 2026

Reason for This PR

Description of Changes

License Acceptance

By submitting this pull request, I confirm that my contribution is made under
the terms of the MIT license.

PR Checklist

[Author TODO: Meet these criteria.]
[Reviewer TODO: Verify that these criteria are met. Request changes if not]

  • All commits in this PR are signed (git commit -s).
  • The reason for this PR is clearly provided (issue no. or explanation).
  • The description of changes is clear and encompassing.
  • Any required documentation changes (code and docs) are included in this PR.
  • Any user-facing changes are mentioned in CHANGELOG.md.
  • All added/changed functionality is tested.

Summary by CodeRabbit

  • New Features

    • Register and sequence multiple gRPC unary interceptors with validation of configured names.
    • Schema extended with a new interceptors configuration property.
  • Tests

    • Added end-to-end tests covering interceptor chaining and log propagation.
    • Added CI workflow to run interceptor end-to-end tests.
  • Chores

    • Bumped Go toolchain to 1.26.
    • Updated Docker build and image metadata handling.

Signed-off-by: Valery Piashchynski <piashchynski.valery@gmail.com>
Copilot AI review requested due to automatic review settings February 18, 2026 16:25
@rustatian rustatian self-assigned this Feb 18, 2026
@rustatian rustatian added the enhancement New feature or request label Feb 18, 2026
@coderabbitai
Copy link

coderabbitai bot commented Feb 18, 2026

📝 Walkthrough

Walkthrough

Adds support for configured unary gRPC interceptor plugins: schema and config additions, server-side validated chaining of named interceptors, two test interceptor implementations with e2e tests, adjustments to tests/util dialing, a Dockerfile/go.mod update, and a CI workflow to run the e2e tests.

Changes

Cohort / File(s) Summary
Configuration & Schema
config.go, schema.json
Added Interceptors []string to runtime config and a root interceptors schema property enforcing unique, non-empty string items.
Server interceptor wiring
server.go
Conditional chaining: validate configured interceptor names exist in the provided registry map; append their UnaryServerInterceptor instances in configured order; return error on missing registration.
End-to-end tests & test interceptors
tests/grpc_interceptors_test.go, tests/doc.go, tests/configs/.rr-grpc-rq-interceptors.yaml, tests/interceptor1/..., tests/interceptor2/..., tests/mock/doc.go
Added e2e test to exercise interceptor chaining; introduced interceptor1 (injects per-request marker) and interceptor2 (verifies marker propagation) plus supporting test package and config with interceptor order change.
Existing test tweak
tests/grpc_plugin_test.go
Replaced net.Dial with context-aware DialContext via net.Dialer to support cancellation/timeouts.
Build image & Go toolchain
protoc_plugins/Dockerfile, protoc_plugins/go.mod
Updated Dockerfile build stages, build metadata args and binary paths; bumped Go version from 1.23 → 1.26 and removed explicit toolchain spec.
CI workflow
.github/workflows/grpc-interceptors.yml
New GitHub Actions workflow to run matrix e2e tests (PHP 8.5, Go stable on ubuntu-latest) for grpc interceptors.

Sequence Diagram(s)

sequenceDiagram
    participant Client as gRPC Client
    participant Server as gRPC Server
    participant Inter1 as Interceptor1
    participant Inter2 as Interceptor2
    participant Handler as Request Handler

    Client->>Server: Send Ping request
    Server->>Inter1: UnaryServerInterceptor (configured order)
    Inter1->>Inter1: Create marker, inject into context
    Inter1->>Inter2: Call next interceptor with context
    Inter2->>Inter2: Read marker from context, log/check
    Inter2->>Handler: Invoke final handler
    Handler-->>Inter2: Return response
    Inter2-->>Inter1: Return response
    Inter1-->>Server: Return response
    Server-->>Client: Send Ping response
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Suggested reviewers

  • wolfy-j

Poem

🐰 I hopped into code with a joyful cheer,
Markers in context, now everyone hears,
Interceptor1 stamps the mark with delight,
Interceptor2 finds it and beams at the sight,
Together they pass requests through the night.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feature: support custom interceptors' clearly and concisely describes the main change: adding custom interceptor support to the gRPC plugin.
Description check ✅ Passed The PR description addresses the template requirements with reason (issue #973), changes summary, and completed checklist items, though specifics on documentation updates and CHANGELOG mention are minimal.
Linked Issues check ✅ Passed The PR implementation meets issue #973 requirements: adds configuration option for custom interceptors, integrates unary server interceptors with validation, and supports interceptor chaining similar to HTTP middleware.
Out of Scope Changes check ✅ Passed All changes are aligned with the PR objective of adding custom interceptor support, though Dockerfile and Go version updates appear tangential to the core feature.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

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

✨ Finishing Touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feature/custom-interceptors

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

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

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This pull request adds support for custom unary server interceptors to the gRPC plugin, allowing users to register and chain custom interceptors for cross-cutting concerns like authentication, rate limiting, and logging.

Changes:

  • Added interceptor configuration support with ordered execution
  • Implemented test interceptors demonstrating context propagation between chained interceptors
  • Added comprehensive end-to-end test suite with GitHub Actions workflow

Reviewed changes

Copilot reviewed 16 out of 17 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
server.go Implements custom interceptor chaining with configuration-based ordering
config.go Adds Interceptors field to support list of interceptor names
schema.json Adds JSON schema documentation for the interceptors configuration
tests/interceptor1/interceptor1.go Test interceptor that creates and propagates context markers
tests/interceptor2/interceptor2.go Test interceptor that validates context propagation
tests/grpc_interceptors_test.go End-to-end test validating interceptor chaining and ordering
tests/configs/.rr-grpc-rq-interceptors.yaml Test configuration demonstrating interceptor setup
.github/workflows/grpc-interceptors.yml CI workflow for automated testing
tests/mock/doc.go Documentation for mock logger package
tests/doc.go Documentation for test package
tests/interceptor1/doc.go Documentation for interceptor1 test package
tests/interceptor2/doc.go Documentation for interceptor2 test package
protoc_plugins/Dockerfile Updates Docker build for improved cross-platform support
protoc_plugins/go.mod Updates Go version specification
protoc_plugins/go.sum Dependency checksum updates
tests/grpc_plugin_test.go Minor improvement to use context-aware dialer
go.work.sum Workspace dependency updates

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
server.go (1)

32-58: ⚠️ Potential issue | 🔴 Critical

p.interceptor (metrics + logging) is silently dropped for all servers without custom interceptors — critical regression.

grpc.ChainUnaryInterceptor(unaryInterceptors...) is only appended to opts inside the if block. When no custom interceptors are configured (the common case for all existing deployments), p.interceptor is never passed to grpc.NewServer. This means Prometheus metrics (rr_grpc_request_duration_seconds, rr_grpc_request_total, rr_grpc_requests_queue) and per-request structured logs are silently lost. TestGRPCMetrics will fail against the unmodified metrics config.

The AI summary confirms this was previously applied unconditionally outside the block and was removed in this PR.

Fix: always apply the interceptor chain; only conditionally append custom interceptors:

🐛 Proposed fix
 	unaryInterceptors := []grpc.UnaryServerInterceptor{
 		grpc.UnaryServerInterceptor(p.interceptor),
 	}
 
-	// if we have interceptors in the config, we need to chain them with our interceptor, and add them to the server options
-	if len(p.config.Interceptors) > 0 && len(interceptors) > 0 {
-		// we need to loop backwards, since the first interceptor in the list should be the last one to execute, and the last one should be the first to execute
-		for i := len(p.config.Interceptors) - 1; i >= 0; i-- {
-			name := p.config.Interceptors[i]
-			if _, ok := interceptors[name]; !ok {
-				// we should raise an error here, since we may silently ignore let's say auth interceptor, which is critical for security
-				return nil, errors.E(op, errors.Str(fmt.Sprintf("interceptor %s is not registered", name)))
-			}
-
-			unaryInterceptors = append(
-				unaryInterceptors,
-				interceptors[name].UnaryServerInterceptor(),
-			)
-		}
-
-		opts = append(
-			opts,
-			grpc.ChainUnaryInterceptor(
-				unaryInterceptors...,
-			),
-		)
-	}
+	if len(p.config.Interceptors) > 0 {
+		for _, name := range p.config.Interceptors {
+			if _, ok := interceptors[name]; !ok {
+				return nil, errors.E(op, errors.Str(fmt.Sprintf("interceptor %s is not registered", name)))
+			}
+			unaryInterceptors = append(unaryInterceptors, interceptors[name].UnaryServerInterceptor())
+		}
+	}
+
+	opts = append(opts, grpc.ChainUnaryInterceptor(unaryInterceptors...))

Update the test YAML to list interceptors in execution order:

-  interceptors:
-    - "interceptor2"
-    - "interceptor1"
+  interceptors:
+    - "interceptor1"
+    - "interceptor2"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server.go` around lines 32 - 58, The p.interceptor (metrics+logging) is
currently only added when p.config.Interceptors is non-empty, causing it to be
dropped for the common case; fix by always building grpc.ChainUnaryInterceptor
with p.interceptor included and only conditionally append additional
interceptors from p.config.Interceptors: ensure unaryInterceptors is initialized
with grpc.UnaryServerInterceptor(p.interceptor), then if
len(p.config.Interceptors)>0 iterate the config (reverse order) and append
interceptors[name].UnaryServerInterceptor() after validating presence in the
interceptors map, and finally always append opts with
grpc.ChainUnaryInterceptor(unaryInterceptors...) before grpc.NewServer so
p.interceptor is applied even when no custom interceptors are configured.
🧹 Nitpick comments (1)
config.go (1)

32-33: LGTM — new Interceptors field is clean.

The field follows the same pattern as other config fields. Schema-level validation (non-empty strings) combined with runtime resolution in server.go provides adequate coverage.

Consider adding a brief doc comment for the field (similar to Env's comment on line 31) to describe what the interceptor names reference.

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

In `@config.go` around lines 32 - 33, Add a short doc comment above the
Interceptors field in the Config struct to explain that Interceptors is a list
of interceptor names (strings) that will be resolved at runtime in server.go,
similar to the existing comment for Env; reference the Interceptors field and
Env field so readers know the pattern and where names are resolved.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@protoc_plugins/Dockerfile`:
- Around line 15-21: The Dockerfile incorrectly strips the leading "v" from
TARGETVARIANT when setting GOAMD64; update the variable assignment so that for
the amd64 branch you preserve the "v" prefix (set goamd64="${TARGETVARIANT}"
when TARGETARCH=amd64 and TARGETVARIANT is non-empty) while keeping the existing
stripping behavior for GOARM (goarm="${TARGETVARIANT#v}"); ensure the
GOOS/GOARCH/GOARM/GOAMD64 export line continues to use GOAMD64 so builds with
platforms like linux/amd64/v3 produce GOAMD64="v3".

In `@schema.json`:
- Around line 37-53: The "interceptors" array schema allows duplicate entries
which causes duplicate registration in server.go; update the "interceptors"
property in schema.json to include "uniqueItems": true so JSON Schema validation
rejects duplicate interceptor names (keep the existing "type", "items", and
"examples" unchanged and only add the uniqueItems constraint for the
"interceptors" definition).

In `@server.go`:
- Around line 37-50: The reverse loop over p.config.Interceptors causes the
configured order to be inverted (first config entry executes last); change the
loop in the block that builds unaryInterceptors to iterate forward from i := 0
to len(p.config.Interceptors)-1, keep the existing missing-interceptor check
(the errors.E call using op) and append
interceptors[name].UnaryServerInterceptor() in that forward order so config
order matches execution; also update any tests/YAML (e.g., the test that used
[interceptor2, interceptor1]) to use the natural order [interceptor1,
interceptor2].

In `@tests/configs/.rr-grpc-rq-interceptors.yaml`:
- Around line 21-23: The YAML test config currently lists interceptors in
reversed order ("interceptor2", "interceptor1") as a temporary workaround for
the server.go reverse-iteration bug; after you fix the iteration in server.go,
update the interceptors array to ["interceptor1", "interceptor2"] so declaration
and runtime execution order match. Locate the interceptors list in the config
and flip the two entries to the natural declaration order once the
reverse-iteration in server.go is corrected.

In `@tests/interceptor2/interceptor2.go`:
- Around line 30-40: In UnaryServerInterceptor (method
Plugin.UnaryServerInterceptor) replace the panic when
interceptor1.MarkerFromContext(ctx) returns false with returning a gRPC status
error (e.g., status.Errorf with an appropriate codes.* value) instead of calling
panic; update the error return path so the interceptor logs the failure via
p.logger() and returns (nil, status.Error(...)) (or similar) so the RPC
goroutine does not crash, and ensure the status and codes packages are
imported/used in the interceptor code.

---

Outside diff comments:
In `@server.go`:
- Around line 32-58: The p.interceptor (metrics+logging) is currently only added
when p.config.Interceptors is non-empty, causing it to be dropped for the common
case; fix by always building grpc.ChainUnaryInterceptor with p.interceptor
included and only conditionally append additional interceptors from
p.config.Interceptors: ensure unaryInterceptors is initialized with
grpc.UnaryServerInterceptor(p.interceptor), then if len(p.config.Interceptors)>0
iterate the config (reverse order) and append
interceptors[name].UnaryServerInterceptor() after validating presence in the
interceptors map, and finally always append opts with
grpc.ChainUnaryInterceptor(unaryInterceptors...) before grpc.NewServer so
p.interceptor is applied even when no custom interceptors are configured.

---

Nitpick comments:
In `@config.go`:
- Around line 32-33: Add a short doc comment above the Interceptors field in the
Config struct to explain that Interceptors is a list of interceptor names
(strings) that will be resolved at runtime in server.go, similar to the existing
comment for Env; reference the Interceptors field and Env field so readers know
the pattern and where names are resolved.

Signed-off-by: Valery Piashchynski <piashchynski.valery@gmail.com>
Signed-off-by: Valery Piashchynski <piashchynski.valery@gmail.com>
Signed-off-by: Valery Piashchynski <piashchynski.valery@gmail.com>
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (2)
server.go (1)

43-43: errors.Str(fmt.Sprintf(...)) is redundant.

fmt.Sprintf already returns a string; wrapping it in errors.Str just re-converts the same value. Use fmt.Errorf (or errors.Errorf if the package exposes one) directly.

♻️ Proposed simplification
-			return nil, errors.E(op, errors.Str(fmt.Sprintf("interceptor %s is not registered", name)))
+			return nil, errors.E(op, fmt.Errorf("interceptor %s is not registered", name))
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server.go` at line 43, The return builds an error with errors.E(op,
errors.Str(fmt.Sprintf("interceptor %s is not registered", name))) which
redundantly wraps a fmt.Sprintf string in errors.Str; replace the inner
expression with an actual error value (e.g. fmt.Errorf("interceptor %s is not
registered", name) or errors.Errorf(...) if the errors package provides it) so
the call becomes errors.E(op, fmt.Errorf(...)) (or errors.E(op,
errors.Errorf(...))) and remove errors.Str and fmt.Sprintf wrapping.
protoc_plugins/Dockerfile (1)

3-9: Consider documenting the BuildKit requirement.

The TARGETPLATFORM ARG is only auto-populated by BuildKit. Without BuildKit (DOCKER_BUILDKIT=0), the ARG is empty and the test on Line 9 will fail immediately with the platform error message rather than a clear "BuildKit required" message. This is a minor edge case since BuildKit has been the default since Docker 20.10, and the guard is intentional per the buf custom-plugin constraint (the linked comment explains this). No action strictly required, but a brief comment at Line 3 or a more specific error message would aid debugging.

♻️ Suggested improvement to error message
-RUN test "${TARGETPLATFORM}" = "linux/amd64" || (echo "buf plugin image must be built for linux/amd64" && exit 1)
+RUN test "${TARGETPLATFORM}" = "linux/amd64" || \
+    (echo "ERROR: buf plugin image must be built for linux/amd64 (got '${TARGETPLATFORM}'). Ensure Docker BuildKit is enabled and use --platform=linux/amd64." && exit 1)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@protoc_plugins/Dockerfile` around lines 3 - 9, The Dockerfile's check using
ARG TARGETPLATFORM and the RUN test (the line that tests "${TARGETPLATFORM}" =
"linux/amd64") can fail silently when BuildKit is not enabled because
TARGETPLATFORM is only set by BuildKit; update the Dockerfile to either (a) add
a short comment above ARG TARGETPLATFORM stating "Requires BuildKit
(DOCKER_BUILDKIT=1) because TARGETPLATFORM is auto-populated by BuildKit", or
(b) replace the current failure message produced by the RUN test with a more
specific error text that mentions BuildKit is required (referencing
TARGETPLATFORM and the RUN test condition) so users get a clear hint when
TARGETPLATFORM is empty.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@protoc_plugins/Dockerfile`:
- Line 18: The final image uses "FROM scratch" so the binary runs as root;
replace that final-stage base with a non-root distroless image (e.g.
"gcr.io/distroless/static-debian12:nonroot") or, if you must keep scratch, add a
numeric non-root user and switch to it (add a USER instruction with a fixed
UID/GID and ensure the built binary and any runtime dirs are chowned to that
UID/GID). Update the Dockerfile's final stage where "FROM scratch" appears and
ensure the binary file ownership and permissions match the chosen non-root UID
before the USER switch.
- Around line 18-31: ARG BUILD_TIME and ARG APP_VERSION default to empty strings
which yields empty OCI labels; change their defaults to a non-empty sentinel
(for example ARG BUILD_TIME="unknown" and ARG APP_VERSION="unknown") and keep
the LABEL org.opencontainers.image.created="${BUILD_TIME}" and
org.opencontainers.image.version="${APP_VERSION}" as-is, and add a short note
(or CI/build-doc) to supply real values via --build-arg BUILD_TIME="$(date -u
+%Y-%m-%dT%H:%M:%SZ)" and --build-arg APP_VERSION="…" when building so the
LABELs are never blank.

In `@server.go`:
- Line 37: Remove the redundant guard "len(interceptors) > 0" from the
conditional so the check runs whenever p.config.Interceptors is non-empty;
iterate over p.config.Interceptors and for each name perform the existing lookup
"if _, ok := interceptors[name]; !ok" to return the missing-interceptor error
(this works correctly for nil/empty maps because map lookups return (zero,
false)). In short, change the if that currently reads "if
len(p.config.Interceptors) > 0 && len(interceptors) > 0" to only check "if
len(p.config.Interceptors) > 0" and keep the inner per-name lookup and
error-return logic intact (referencing p.config.Interceptors and interceptors).

---

Duplicate comments:
In `@server.go`:
- Around line 36-51: Keep the forward-order iteration and explicit error
handling introduced in server.go: iterate p.config.Interceptors from i=0 to
len-1, validate each name exists in the interceptors map and return the error
via errors.E(op, errors.Str(fmt.Sprintf("interceptor %s is not registered",
name))) if missing, and append interceptors[name].UnaryServerInterceptor() into
unaryInterceptors so execution order matches configuration (symbols:
p.config.Interceptors, interceptors, unaryInterceptors, UnaryServerInterceptor,
errors.E, op).

---

Nitpick comments:
In `@protoc_plugins/Dockerfile`:
- Around line 3-9: The Dockerfile's check using ARG TARGETPLATFORM and the RUN
test (the line that tests "${TARGETPLATFORM}" = "linux/amd64") can fail silently
when BuildKit is not enabled because TARGETPLATFORM is only set by BuildKit;
update the Dockerfile to either (a) add a short comment above ARG TARGETPLATFORM
stating "Requires BuildKit (DOCKER_BUILDKIT=1) because TARGETPLATFORM is
auto-populated by BuildKit", or (b) replace the current failure message produced
by the RUN test with a more specific error text that mentions BuildKit is
required (referencing TARGETPLATFORM and the RUN test condition) so users get a
clear hint when TARGETPLATFORM is empty.

In `@server.go`:
- Line 43: The return builds an error with errors.E(op,
errors.Str(fmt.Sprintf("interceptor %s is not registered", name))) which
redundantly wraps a fmt.Sprintf string in errors.Str; replace the inner
expression with an actual error value (e.g. fmt.Errorf("interceptor %s is not
registered", name) or errors.Errorf(...) if the errors package provides it) so
the call becomes errors.E(op, fmt.Errorf(...)) (or errors.E(op,
errors.Errorf(...))) and remove errors.Str and fmt.Sprintf wrapping.

Signed-off-by: Valery Piashchynski <piashchynski.valery@gmail.com>
@rustatian rustatian merged commit d7b71e6 into master Feb 18, 2026
7 checks passed
@rustatian rustatian deleted the feature/custom-interceptors branch February 18, 2026 17:38
@codecov
Copy link

codecov bot commented Feb 18, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 0.00%. Comparing base (d904d39) to head (0c73e3f).
⚠️ Report is 6 commits behind head on master.

Additional details and impacted files
@@      Coverage Diff      @@
##   master   #143   +/-   ##
=============================
=============================

☔ View full report in Codecov by Sentry.
📢 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.

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

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[💡 FEATURE REQUEST]: gRPC plugin custom interceptors

2 participants