Observability & Testing
You can’t curl a binary protocol
Section titled “You can’t curl a binary protocol”Protobuf’s efficiency costs you human-readability. You can’t eyeball a gRPC call the way you can a REST request. So the ecosystem replaces “read the traffic” with reflection, interceptor-based telemetry, and in-process tests.
Reflection: explore a live server
Section titled “Reflection: explore a live server”Server reflection lets a server describe its own services and messages at runtime, so a tool can call methods without having the .proto on hand. With it enabled, grpcurl becomes your curl for gRPC:
# List services on a running server, then call one — no local .proto neededgrpcurl -plaintext localhost:50051 listgrpcurl -plaintext -d '{"id": 42}' localhost:50051 user.v1.UserService/GetUserEnable reflection in non-production or behind auth — it’s what makes a running server explorable.
Telemetry rides on interceptors
Section titled “Telemetry rides on interceptors”You already met interceptors as the place for cross-cutting concerns. Observability is their biggest use: one interceptor chain adds metrics (latency, request counts, status codes), traces (spans that follow a call across services), and structured logs — for every method, without touching handler code.
flowchart LR call["incoming RPC"] --> mw["interceptor chain"] mw --> m["metrics (latency, count, code)"] mw --> t["trace span (propagate context)"] mw --> l["structured log"] mw --> handler["your handler"]
The standard path is OpenTelemetry instrumentation, which ships as ready-made gRPC interceptors in every major language — you register them once and get metrics and distributed traces across your whole fleet.
Test in-process, not over the network
Section titled “Test in-process, not over the network”Spinning up real ports makes tests slow and flaky. gRPC lets you run a real server and client in memory, exercising the full serialization and interceptor path with no network:
// bufconn: a real gRPC server over an in-memory listenerlis := bufconn.Listen(1024 * 1024)s := grpc.NewServer()userv1.RegisterUserServiceServer(s, &server{})go s.Serve(lis)
conn, _ := grpc.NewClient("passthrough:///bufnet", grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) { return lis.DialContext(ctx) }), grpc.WithTransportCredentials(insecure.NewCredentials()))client := userv1.NewUserServiceClient(conn)got, err := client.GetUser(ctx, &userv1.GetUserRequest{Id: 42})# Start a real server on an ephemeral port inside the testserver = grpc.server(futures.ThreadPoolExecutor(max_workers=1))user_pb2_grpc.add_UserServiceServicer_to_server(Servicer(), server)port = server.add_insecure_port("localhost:0")server.start()
channel = grpc.insecure_channel(f"localhost:{port}")client = user_pb2_grpc.UserServiceStub(channel)got = client.GetUser(user_pb2.GetUserRequest(id=42))server.stop(None)// Bind to port 0, let the OS pick a free port, connect to itconst server = new Server();server.addService(UserServiceService, new UserServiceImpl());server.bindAsync('localhost:0', ServerCredentials.createInsecure(), (_e, port) => { const client = new UserServiceClient(`localhost:${port}`, credentials.createInsecure()); client.getUser({ id: 42 }, (err, user) => { /* assert */ });});Benchmark honestly
Section titled “Benchmark honestly”gRPC is usually faster than REST/JSON for high-volume, small, structured calls — less bytes, cheaper decode, multiplexed connections. But measure your workload: for occasional calls or large already-compressed blobs, the difference can be negligible, and REST’s tooling may win overall. Benchmark with realistic payloads and concurrency, not a toy loop, before making it a selling point.