This episode covers metadata as RPC headers, client and server interceptors for both unary and streaming, and lifecycle callbacks for request logging, tracing, and authorization context that can be reused across all your services.

Every RPC can carry extra information beyond the main payload. That information is metadata — key-value pairs sent as HTTP/2 headers, like authentication tokens, trace IDs, or the region the request came from. Metadata is useful on its own, but its real power appears when combined with interceptors.
An interceptor is gRPC middleware: code that runs before every RPC is processed, without having to be rewritten in every method. Episode 6 covers metadata, client and server interceptors for both unary and streaming, and lifecycle callbacks for logging, tracing, and authorization.
The client sends metadata via context. A convention to remember: keys in metadata must be lowercase, because HTTP/2 doesn't distinguish letter case in headers:
ctx := metadata.AppendToOutgoingContext(
ctx,
"authorization", "Bearer <token>",
"x-trace-id", traceID,
)
res, err := client.GetProduct(ctx, &pb.ProductId{Id: "p-001"})metadata.AppendToOutgoingContext(ctx, "authorization", "Bearer ...") attaches two key-value pairs to the context, which are then sent as headers.
On the server side, metadata is read from the incoming context:
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return nil, status.Error(codes.InvalidArgument, "metadata kosong")
}
token := strings.TrimPrefix(md.Get("authorization")[0], "Bearer ")Note that md.Get("authorization") returns a slice, because one key can have many values. The server can also send metadata back via grpc.SendHeader.
Interceptors avoid repeating the same boilerplate across all calls. Here's a unary client interceptor example that adds a trace ID and measures latency:
func tracingUnaryClient(
ctx context.Context,
method string,
req, reply any,
cc *grpc.ClientConn,
invoker grpc.UnaryInvoker,
opts ...grpc.CallOption,
) error {
start := time.Now()
err := invoker(ctx, method, req, reply, cc, opts...)
log.Printf("method %s selesai dalam %s", method, time.Since(start))
return err
}The interceptor is installed when the channel is created:
conn, _ := grpc.NewClient("localhost:50051",
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithUnaryInterceptor(tracingUnaryClient),
)The grpc.WithUnaryInterceptor(tracingUnaryClient) function registers the interceptor; for streaming use grpc.WithStreamInterceptor.
A server interceptor wraps every handler. This is the right place for authorization, logging, or panic recovery:
func authUnaryServer(
ctx context.Context,
req any,
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler,
) (any, error) {
md, _ := metadata.FromIncomingContext(ctx)
if len(md["authorization"]) == 0 {
return nil, status.Error(codes.Unauthenticated, "token diperlukan")
}
if err := validateToken(md["authorization"][0]); err != nil {
return nil, status.Error(codes.Unauthenticated, "token tidak valid")
}
return handler(ctx, req)
}The important pattern: validate first, then call handler(ctx, req) to continue into the method. With this, one interceptor protects the entire service without touching a single handler.
Interceptors are where lifecycle callbacks live: an entry point before the RPC, and an exit point after the RPC completes — similar to defer in Go. Combine both for full observability:
func logUnaryServer(ctx context.Context, req any,
info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
start := time.Now()
res, err := handler(ctx, req)
st, _ := status.FromError(err)
log.Printf("method=%s code=%s durasi=%s",
info.FullMethod, st.Code(), time.Since(start))
return res, err
}Log one line per call with the method, status code, and duration — the foundation of the observability that will be built in full in episode 14. info.FullMethod contains the full name like catalog.v1.CatalogService/GetProduct.
For streaming, the lifecycle is accessed via a wrapper: the StreamServerInterceptor wraps stream.Recv and stream.Send so every message can be counted or monitored.
Key takeaways:
WithUnaryInterceptor and WithStreamInterceptor.handler lets a single interceptor protect the whole service.In episode 7 next, we cover gRPC configuration, environment variables, and local deployment — composing environment-aware configuration, using environment variables for port, TLS, service discovery, and retry policy, then running the gRPC server inside a container and docker-compose. Your interceptors will move from the local machine to the production environment.