Files
zitadel/apps/login/src/lib/grpc/interceptors/otel.ts
T
fca2589fc3 fix(login): http 2 memory leak's (#11830)
# Which Problems Are Solved
All http 2 based connect-es clients caused memory leaks in our testing
using the login v2 application as the host.

# How the Problems Are Solved
Switch to connect-rpc based http 1.1 transport for the login v2

# Additional Changes
Improved the otel interceptor implementation and tests.

# Additional Context
- connect-rpc is only enabled for v2 API's so we have to make sure to
not use any v1 apis in the login but this should be the case anyways.
- Discussion #10562

---------

Co-authored-by: Max Peintner <[email protected]>
2026-03-17 14:29:21 +00:00

88 lines
2.6 KiB
TypeScript

/**
* OpenTelemetry gRPC/Connect Interceptor
*
* Provides distributed tracing for Connect/gRPC calls. Creates client spans
* with RPC semantic conventions, propagates W3C trace context headers
* (traceparent/tracestate) and baggage, and records errors with gRPC status
* codes.
*
* @see https://opentelemetry.io/docs/specs/semconv/rpc/rpc-spans/
*
* @example
* ```typescript
* import { otelGrpcInterceptor } from "@/lib/grpc/interceptors/otel";
*
* const transport = createTransport({
* baseUrl: "https://api.example.com",
* interceptors: [otelGrpcInterceptor],
* });
* ```
*/
import { ConnectError, Interceptor } from "@connectrpc/connect";
import { context, propagation, SpanKind, SpanStatusCode, trace } from "@opentelemetry/api";
const TRACER_NAME = "zitadel-login-grpc" as const;
/**
* Extracts the hostname from a URL string. Returns "unknown" if the URL is
* undefined or cannot be parsed.
*/
function parseHostname(url: string): string {
try {
return new URL(url).hostname;
} catch {
return "unknown";
}
}
/**
* OpenTelemetry interceptor for Connect/gRPC calls. Wraps each RPC call in a
* client span, injects trace context headers for distributed tracing, and
* records any errors that occur during the call.
*/
export const otelGrpcInterceptor: Interceptor = (next) =>
async function tracedCall(req) {
const serviceName: string = req.service?.typeName ?? "unknown";
const methodName: string = req.method?.name ?? "unknown";
return trace.getTracer(TRACER_NAME).startActiveSpan(
`${serviceName}/${methodName}`,
{
kind: SpanKind.CLIENT,
attributes: {
"rpc.system": "grpc",
"rpc.service": serviceName,
"rpc.method": methodName,
"server.address": parseHostname(req.url),
},
},
async (span) => {
const ctx = trace.setSpan(context.active(), span);
propagation.inject(ctx, req.header, {
set: (carrier, key, value) => carrier.set(key, value),
});
try {
const response = await next(req);
span.setStatus({ code: SpanStatusCode.OK });
return response;
} catch (err) {
const exception = err instanceof Error ? err : new Error(String(err));
span.recordException(exception);
if (exception instanceof ConnectError) {
span.setAttribute("rpc.grpc.status_code", exception.code);
}
span.setStatus({
code: SpanStatusCode.ERROR,
message: exception.message,
});
throw err;
} finally {
span.end();
}
},
);
};