diff --git a/.devcontainer/docker-compose.yaml b/.devcontainer/docker-compose.yaml index 3cb34f0a1..a3999ea19 100644 --- a/.devcontainer/docker-compose.yaml +++ b/.devcontainer/docker-compose.yaml @@ -1,5 +1,4 @@ services: - devcontainer: image: mcr.microsoft.com/devcontainers/typescript-node:22-bookworm volumes: @@ -15,7 +14,6 @@ services: DEVCONTAINER_DB_HOST: db DEVCONTAINER_API_HOST: api DEVCONTAINER_LOGIN_HOST: login - DEVCONTAINER_LOGIN_API_MOCK_HOST: login-api-mock DEVCONTAINER_DB_API_INTEGRATION_HOST: db-api-integration DEVCONTAINER_DB_FUNCTIONAL_UI_HOST: db-functional-ui ports: @@ -31,58 +29,49 @@ services: volumes: - db-data-dev:/var/lib/postgresql healthcheck: - test: [ "CMD-SHELL", "pg_isready", "-U", "postgres" ] + test: ["CMD-SHELL", "pg_isready", "-U", "postgres"] interval: "10s" timeout: "30s" retries: 5 start_period: "20s" ports: - - 5432:5432 + - "5432:5432" db-api-integration: - image: 'postgres:18' + image: "postgres:18" env_file: - ./.env.db environment: PGPORT: 5433 command: postgres -c shared_preload_libraries=pg_stat_statements -c pg_stat_statements.track=all -c shared_buffers=1GB -c work_mem=16MB -c effective_io_concurrency=100 -c wal_level=minimal -c archive_mode=off -c max_wal_senders=0 healthcheck: - test: [ "CMD-SHELL", "pg_isready", "-U", "postgres" ] - interval: '10s' - timeout: '30s' + test: ["CMD-SHELL", "pg_isready", "-U", "postgres"] + interval: "10s" + timeout: "30s" retries: 5 - start_period: '20s' + start_period: "20s" ports: - - 5433:5433 + - "5433:5433" cache-api-integration: - image: 'redis:8' + image: "redis:8" ports: - - 6379:6379 + - "6379:6379" db-functional-ui: - image: 'postgres:18' + image: "postgres:18" env_file: - ./.env.db environment: PGPORT: 5434 healthcheck: - test: [ "CMD-SHELL", "pg_isready", "-U", "postgres" ] - interval: '10s' - timeout: '30s' + test: ["CMD-SHELL", "pg_isready", "-U", "postgres"] + interval: "10s" + timeout: "30s" retries: 5 - start_period: '20s' + start_period: "20s" ports: - "5434:5434" - login-api-mock: - build: - context: ../apps/login/integration/api-mock - additional_contexts: - zitadel-protos: ../proto - ports: - - 22220:22220 - - 22222:22222 - volumes: db-data-dev: diff --git a/apps/login/dockerized/otel/mock-zitadel-server.js b/apps/login/dockerized/otel/mock-zitadel-server.js index 5b32af2dc..ffa63a8b6 100644 --- a/apps/login/dockerized/otel/mock-zitadel-server.js +++ b/apps/login/dockerized/otel/mock-zitadel-server.js @@ -1,10 +1,7 @@ /** - * Mock Zitadel Connect/gRPC server for testing trace propagation. + * Mock Zitadel ConnectRPC server for testing trace propagation. * Captures incoming headers and writes them to a file for test assertions. - * - * Uses HTTP/2 with cleartext (h2c) to support gRPC protocol. */ -const http2 = require("http2"); const http = require("http"); const fs = require("fs"); const path = require("path"); @@ -34,21 +31,18 @@ function safeJsonStringify(value) { // Store captured requests const capturedRequests = []; -// Handle HTTP/2 request -function handleRequest(stream, headers, isHttp2 = true) { - const method = isHttp2 ? headers[":method"] : headers.method; - const url = isHttp2 ? headers[":path"] : headers.url; +const server = http.createServer((req, res) => { + const { method, url } = req; // Capture trace headers const traceHeaders = { timestamp: new Date().toISOString(), - method: method, - url: url, - traceparent: headers["traceparent"] || null, - tracestate: headers["tracestate"] || null, - baggage: headers["baggage"] || null, - grpcAcceptEncoding: headers["grpc-accept-encoding"] || null, - contentType: headers["content-type"] || null, + method, + url, + traceparent: req.headers["traceparent"] || null, + tracestate: req.headers["tracestate"] || null, + baggage: req.headers["baggage"] || null, + contentType: req.headers["content-type"] || null, }; capturedRequests.push(traceHeaders); @@ -60,24 +54,9 @@ function handleRequest(stream, headers, isHttp2 = true) { console.log(`[${traceHeaders.timestamp}] ${method} ${url}`); console.log(` traceparent: ${traceHeaders.traceparent || "(none)"}`); - // Helper to send response const sendResponse = (statusCode, contentType, body) => { - if (isHttp2) { - stream.respond({ - ":status": statusCode, - "content-type": contentType, - "grpc-status": "0", - "grpc-message": "OK", - }); - stream.end(body); - } else { - stream.writeHead(statusCode, { - "Content-Type": contentType, - "grpc-status": "0", - "grpc-message": "OK", - }); - stream.end(body); - } + res.writeHead(statusCode, { "Content-Type": contentType }); + res.end(body); }; // Health check @@ -92,7 +71,7 @@ function handleRequest(stream, headers, isHttp2 = true) { return; } - // Mock Connect/gRPC responses + // Mock ConnectRPC responses // These are minimal responses to prevent the login app from crashing if (url.includes("GetLoginSettings") || url.includes("SettingsService")) { sendResponse(200, "application/json", safeJsonStringify({ settings: {} })); @@ -109,47 +88,20 @@ function handleRequest(stream, headers, isHttp2 = true) { } else { sendResponse(200, "application/json", safeJsonStringify({})); } -} - -// Create HTTP/2 server (cleartext for testing) -const http2Server = http2.createServer(); - -http2Server.on("stream", (stream, headers) => { - handleRequest(stream, headers, true); }); -http2Server.on("error", (err) => console.error("HTTP/2 error:", err)); +server.on("error", (err) => console.error("Server error:", err)); -// Also create HTTP/1.1 server for health checks from wget -const httpServer = http.createServer((req, res) => { - // Create a headers object compatible with handleRequest - const headers = { - method: req.method, - url: req.url, - ...req.headers, - }; - handleRequest(res, headers, false); -}); - -// Start HTTP/2 server on main port -http2Server.listen(PORT, "0.0.0.0", () => { - console.log(`Mock Zitadel HTTP/2 server listening on port ${PORT}`); +server.listen(PORT, "0.0.0.0", () => { + console.log(`Mock Zitadel server listening on port ${PORT}`); console.log(`Captured headers will be written to: ${HEADERS_FILE}`); }); -// Start HTTP/1.1 server on a different port for health checks -const HEALTH_PORT = 7432; -httpServer.listen(HEALTH_PORT, "0.0.0.0", () => { - console.log(`Mock Zitadel HTTP/1.1 health server listening on port ${HEALTH_PORT}`); -}); - // Graceful shutdown process.on("SIGTERM", () => { - console.log("Shutting down mock servers..."); - http2Server.close(() => { - httpServer.close(() => { - console.log("Mock servers stopped"); - process.exit(0); - }); + console.log("Shutting down mock server..."); + server.close(() => { + console.log("Mock server stopped"); + process.exit(0); }); }); diff --git a/apps/login/dockerized/otel/otel.integration.test.ts b/apps/login/dockerized/otel/otel.integration.test.ts index 0c28f05d0..7f1bd8377 100644 --- a/apps/login/dockerized/otel/otel.integration.test.ts +++ b/apps/login/dockerized/otel/otel.integration.test.ts @@ -1,8 +1,8 @@ -import { describe, it, expect, beforeAll, afterAll } from "vitest"; -import { GenericContainer, type StartedTestContainer, Network, Wait } from "testcontainers"; -import type { StartedNetwork } from "testcontainers"; import * as fs from "fs"; import * as path from "path"; +import type { StartedNetwork } from "testcontainers"; +import { GenericContainer, Network, Wait, type StartedTestContainer } from "testcontainers"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; const TEST_DIR = path.dirname(new URL(import.meta.url).pathname); const OUTPUT_DIR = path.join(TEST_DIR, "output"); @@ -123,17 +123,15 @@ describe("OpenTelemetry Integration", () => { mockZitadel = await new GenericContainer("node:20-alpine") .withNetwork(network) .withNetworkAliases("mock-zitadel") - .withCopyFilesToContainer([ - { source: path.join(TEST_DIR, "mock-zitadel-server.js"), target: "/app/server.js" }, - ]) + .withCopyFilesToContainer([{ source: path.join(TEST_DIR, "mock-zitadel-server.js"), target: "/app/server.js" }]) .withBindMounts([{ source: OUTPUT_DIR, target: "/tmp/otel" }]) .withEnvironment({ PORT: "8080", OUTPUT_DIR: "/tmp/otel", }) .withCommand(["node", "/app/server.js"]) - .withExposedPorts(8080, 7432) - .withWaitStrategy(Wait.forLogMessage("Mock Zitadel HTTP/2 server listening")) + .withExposedPorts(8080) + .withWaitStrategy(Wait.forLogMessage("Mock Zitadel server listening")) .start(); try { @@ -195,607 +193,1079 @@ describe("OpenTelemetry Integration", () => { describe("Traces", () => { describe("OTLP Export", () => { - it("exports spans to collector", () => { - expect(readTraces().length).toBeGreaterThan(0); - }, TEST_TIMEOUT); + it( + "exports spans to collector", + () => { + expect(readTraces().length).toBeGreaterThan(0); + }, + TEST_TIMEOUT, + ); - it("contains valid trace IDs", () => { - expect(readTraces()).toMatch(/"traceId":"[a-f0-9]{32}"/); - }, TEST_TIMEOUT); + it( + "contains valid trace IDs", + () => { + expect(readTraces()).toMatch(/"traceId":"[a-f0-9]{32}"/); + }, + TEST_TIMEOUT, + ); - it("contains valid span IDs", () => { - expect(readTraces()).toMatch(/"spanId":"[a-f0-9]{16}"/); - }, TEST_TIMEOUT); + it( + "contains valid span IDs", + () => { + expect(readTraces()).toMatch(/"spanId":"[a-f0-9]{16}"/); + }, + TEST_TIMEOUT, + ); - it("contains start timestamps", () => { - expect(readTraces()).toContain("startTimeUnixNano"); - }, TEST_TIMEOUT); + it( + "contains start timestamps", + () => { + expect(readTraces()).toContain("startTimeUnixNano"); + }, + TEST_TIMEOUT, + ); - it("contains end timestamps", () => { - expect(readTraces()).toContain("endTimeUnixNano"); - }, TEST_TIMEOUT); + it( + "contains end timestamps", + () => { + expect(readTraces()).toContain("endTimeUnixNano"); + }, + TEST_TIMEOUT, + ); - it("contains span status", () => { - expect(readTraces()).toContain('"status"'); - }, TEST_TIMEOUT); + it( + "contains span status", + () => { + expect(readTraces()).toContain('"status"'); + }, + TEST_TIMEOUT, + ); - it("contains span kind", () => { - expect(readTraces()).toContain('"kind"'); - }, TEST_TIMEOUT); + it( + "contains span kind", + () => { + expect(readTraces()).toContain('"kind"'); + }, + TEST_TIMEOUT, + ); - it("contains custom test spans", () => { - expect(readTraces()).toContain("test-span"); - }, TEST_TIMEOUT); + it( + "contains custom test spans", + () => { + expect(readTraces()).toContain("test-span"); + }, + TEST_TIMEOUT, + ); }); describe("Span Attributes", () => { - it("contains http.method", () => { - expect(readTraces()).toContain('"http.method"'); - }, TEST_TIMEOUT); + it( + "contains http.method", + () => { + expect(readTraces()).toContain('"http.method"'); + }, + TEST_TIMEOUT, + ); - it("contains http.url", () => { - expect(readTraces()).toContain('"http.url"'); - }, TEST_TIMEOUT); + it( + "contains http.url", + () => { + expect(readTraces()).toContain('"http.url"'); + }, + TEST_TIMEOUT, + ); - it("contains http.status_code", () => { - expect(readTraces()).toContain('"http.status_code"'); - }, TEST_TIMEOUT); + it( + "contains http.status_code", + () => { + expect(readTraces()).toContain('"http.status_code"'); + }, + TEST_TIMEOUT, + ); - it("contains http.target", () => { - expect(readTraces()).toContain('"http.target"'); - }, TEST_TIMEOUT); + it( + "contains http.target", + () => { + expect(readTraces()).toContain('"http.target"'); + }, + TEST_TIMEOUT, + ); - it("contains net.host.name", () => { - expect(readTraces()).toContain('"net.host.name"'); - }, TEST_TIMEOUT); + it( + "contains net.host.name", + () => { + expect(readTraces()).toContain('"net.host.name"'); + }, + TEST_TIMEOUT, + ); - it("contains net.host.port", () => { - expect(readTraces()).toContain('"net.host.port"'); - }, TEST_TIMEOUT); + it( + "contains net.host.port", + () => { + expect(readTraces()).toContain('"net.host.port"'); + }, + TEST_TIMEOUT, + ); - it("contains custom test.attribute", () => { - expect(readTraces()).toContain('"test.attribute"'); - }, TEST_TIMEOUT); + it( + "contains custom test.attribute", + () => { + expect(readTraces()).toContain('"test.attribute"'); + }, + TEST_TIMEOUT, + ); - it("contains custom test.timestamp", () => { - expect(readTraces()).toContain('"test.timestamp"'); - }, TEST_TIMEOUT); + it( + "contains custom test.timestamp", + () => { + expect(readTraces()).toContain('"test.timestamp"'); + }, + TEST_TIMEOUT, + ); }); describe("Span Status", () => { - it("contains status code OK", () => { - expect(readTraces()).toContain('"code":1'); - }, TEST_TIMEOUT); + it( + "contains status code OK", + () => { + expect(readTraces()).toContain('"code":1'); + }, + TEST_TIMEOUT, + ); - it("contains status code ERROR", () => { - expect(readTraces()).toContain('"code":2'); - }, TEST_TIMEOUT); + it( + "contains status code ERROR", + () => { + expect(readTraces()).toContain('"code":2'); + }, + TEST_TIMEOUT, + ); - it("contains error message on failed spans", () => { - expect(readTraces()).toContain('"message"'); - }, TEST_TIMEOUT); + it( + "contains error message on failed spans", + () => { + expect(readTraces()).toContain('"message"'); + }, + TEST_TIMEOUT, + ); }); describe("Span Hierarchy", () => { - it("contains parent span IDs", () => { - expect(readTraces()).toContain('"parentSpanId"'); - }, TEST_TIMEOUT); + it( + "contains parent span IDs", + () => { + expect(readTraces()).toContain('"parentSpanId"'); + }, + TEST_TIMEOUT, + ); - it("has child spans linked to parent", () => { - const traces = readTraces(); - const parentMatches = traces.match(/"parentSpanId":"([a-f0-9]{16})"/g) || []; - expect(parentMatches.length).toBeGreaterThan(0); - }, TEST_TIMEOUT); + it( + "has child spans linked to parent", + () => { + const traces = readTraces(); + const parentMatches = traces.match(/"parentSpanId":"([a-f0-9]{16})"/g) || []; + expect(parentMatches.length).toBeGreaterThan(0); + }, + TEST_TIMEOUT, + ); }); describe("Span Filtering", () => { - it("HTTP instrumentation excludes /healthy", () => { - const tracesContent = readTraces(); - const traceObjects = tracesContent - .split("\n") - .filter((line) => line.trim()) - .map((line) => JSON.parse(line)); + it( + "HTTP instrumentation excludes /healthy", + () => { + const tracesContent = readTraces(); + const traceObjects = tracesContent + .split("\n") + .filter((line) => line.trim()) + .map((line) => JSON.parse(line)); - const allResourceSpans = traceObjects.flatMap((t) => t.resourceSpans || []); + const allResourceSpans = traceObjects.flatMap((t) => t.resourceSpans || []); - const httpSpans = allResourceSpans - .flatMap((rs: { scopeSpans: Array<{ scope: { name: string }; spans: unknown[] }> }) => rs.scopeSpans) - .filter((ss: { scope: { name: string } }) => ss.scope.name === "@opentelemetry/instrumentation-http") - .flatMap((ss: { spans: unknown[] }) => ss.spans); - const healthySpans = httpSpans.filter( - (s: { attributes?: Array<{ key: string; value: { stringValue?: string } }> }) => - s.attributes?.some( - (a: { key: string; value: { stringValue?: string } }) => - a.key === "http.target" && a.value.stringValue?.includes("/healthy"), - ), - ); - expect(healthySpans).toHaveLength(0); - }, TEST_TIMEOUT); + const httpSpans = allResourceSpans + .flatMap((rs: { scopeSpans: Array<{ scope: { name: string }; spans: unknown[] }> }) => rs.scopeSpans) + .filter((ss: { scope: { name: string } }) => ss.scope.name === "@opentelemetry/instrumentation-http") + .flatMap((ss: { spans: unknown[] }) => ss.spans); + const healthySpans = httpSpans.filter( + (s: { attributes?: Array<{ key: string; value: { stringValue?: string } }> }) => + s.attributes?.some( + (a: { key: string; value: { stringValue?: string } }) => + a.key === "http.target" && a.value.stringValue?.includes("/healthy"), + ), + ); + expect(healthySpans).toHaveLength(0); + }, + TEST_TIMEOUT, + ); - it("HTTP instrumentation excludes /ready", () => { - const tracesContent = readTraces(); - const traceObjects = tracesContent - .split("\n") - .filter((line) => line.trim()) - .map((line) => JSON.parse(line)); + it( + "HTTP instrumentation excludes /ready", + () => { + const tracesContent = readTraces(); + const traceObjects = tracesContent + .split("\n") + .filter((line) => line.trim()) + .map((line) => JSON.parse(line)); - const allResourceSpans = traceObjects.flatMap((t) => t.resourceSpans || []); + const allResourceSpans = traceObjects.flatMap((t) => t.resourceSpans || []); - const httpSpans = allResourceSpans - .flatMap((rs: { scopeSpans: Array<{ scope: { name: string }; spans: unknown[] }> }) => rs.scopeSpans) - .filter((ss: { scope: { name: string } }) => ss.scope.name === "@opentelemetry/instrumentation-http") - .flatMap((ss: { spans: unknown[] }) => ss.spans); - const readySpans = httpSpans.filter( - (s: { attributes?: Array<{ key: string; value: { stringValue?: string } }> }) => - s.attributes?.some( - (a: { key: string; value: { stringValue?: string } }) => - a.key === "http.target" && a.value.stringValue?.includes("/ready"), - ), - ); - expect(readySpans).toHaveLength(0); - }, TEST_TIMEOUT); + const httpSpans = allResourceSpans + .flatMap((rs: { scopeSpans: Array<{ scope: { name: string }; spans: unknown[] }> }) => rs.scopeSpans) + .filter((ss: { scope: { name: string } }) => ss.scope.name === "@opentelemetry/instrumentation-http") + .flatMap((ss: { spans: unknown[] }) => ss.spans); + const readySpans = httpSpans.filter( + (s: { attributes?: Array<{ key: string; value: { stringValue?: string } }> }) => + s.attributes?.some( + (a: { key: string; value: { stringValue?: string } }) => + a.key === "http.target" && a.value.stringValue?.includes("/ready"), + ), + ); + expect(readySpans).toHaveLength(0); + }, + TEST_TIMEOUT, + ); - it("includes /otel-test in traces", () => { - const traces = readTraces(); - expect(traces).toContain("otel-test"); - }, TEST_TIMEOUT); + it( + "includes /otel-test in traces", + () => { + const traces = readTraces(); + expect(traces).toContain("otel-test"); + }, + TEST_TIMEOUT, + ); - it("includes /loginname in traces", () => { - const traces = readTraces(); - expect(traces).toContain("loginname"); - }, TEST_TIMEOUT); + it( + "includes /loginname in traces", + () => { + const traces = readTraces(); + expect(traces).toContain("loginname"); + }, + TEST_TIMEOUT, + ); }); describe("Span Events", () => { - it("contains exception events", () => { - expect(readTraces()).toContain('"name":"exception"'); - }, TEST_TIMEOUT); + it( + "contains exception events", + () => { + expect(readTraces()).toContain('"name":"exception"'); + }, + TEST_TIMEOUT, + ); - it("contains exception.type attribute", () => { - expect(readTraces()).toContain('"exception.type"'); - }, TEST_TIMEOUT); + it( + "contains exception.type attribute", + () => { + expect(readTraces()).toContain('"exception.type"'); + }, + TEST_TIMEOUT, + ); - it("contains exception.message attribute", () => { - expect(readTraces()).toContain('"exception.message"'); - }, TEST_TIMEOUT); + it( + "contains exception.message attribute", + () => { + expect(readTraces()).toContain('"exception.message"'); + }, + TEST_TIMEOUT, + ); - it("contains exception.stacktrace attribute", () => { - expect(readTraces()).toContain('"exception.stacktrace"'); - }, TEST_TIMEOUT); + it( + "contains exception.stacktrace attribute", + () => { + expect(readTraces()).toContain('"exception.stacktrace"'); + }, + TEST_TIMEOUT, + ); }); describe("gRPC Spans", () => { - it("contains rpc.system attribute", () => { - expect(readTraces()).toContain('"rpc.system"'); - }, TEST_TIMEOUT); + it( + "contains rpc.system attribute", + () => { + expect(readTraces()).toContain('"rpc.system"'); + }, + TEST_TIMEOUT, + ); - it("contains rpc.service attribute", () => { - expect(readTraces()).toContain('"rpc.service"'); - }, TEST_TIMEOUT); + it( + "contains rpc.service attribute", + () => { + expect(readTraces()).toContain('"rpc.service"'); + }, + TEST_TIMEOUT, + ); - it("contains rpc.method attribute", () => { - expect(readTraces()).toContain('"rpc.method"'); - }, TEST_TIMEOUT); + it( + "contains rpc.method attribute", + () => { + expect(readTraces()).toContain('"rpc.method"'); + }, + TEST_TIMEOUT, + ); - it("contains server.address attribute", () => { - expect(readTraces()).toContain('"server.address"'); - }, TEST_TIMEOUT); + it( + "contains server.address attribute", + () => { + expect(readTraces()).toContain('"server.address"'); + }, + TEST_TIMEOUT, + ); - it("contains rpc.grpc.status_code attribute", () => { - expect(readTraces()).toContain('"rpc.grpc.status_code"'); - }, TEST_TIMEOUT); + it( + "contains rpc.grpc.status_code attribute", + () => { + expect(readTraces()).toContain('"rpc.grpc.status_code"'); + }, + TEST_TIMEOUT, + ); - it("identifies grpc as rpc system", () => { - expect(readTraces()).toContain('"grpc"'); - }, TEST_TIMEOUT); + it( + "identifies grpc as rpc system", + () => { + expect(readTraces()).toContain('"grpc"'); + }, + TEST_TIMEOUT, + ); }); describe("HTTP Status Codes", () => { - it("records HTTP 200 status", () => { - expect(readTraces()).toContain('"intValue":"200"'); - }, TEST_TIMEOUT); + it( + "records HTTP 200 status", + () => { + expect(readTraces()).toContain('"intValue":"200"'); + }, + TEST_TIMEOUT, + ); - it("records HTTP 404 status", () => { - expect(readTraces()).toContain('"intValue":"404"'); - }, TEST_TIMEOUT); + it( + "records HTTP 404 status", + () => { + expect(readTraces()).toContain('"intValue":"404"'); + }, + TEST_TIMEOUT, + ); - it("records OK status text", () => { - expect(readTraces()).toContain('"OK"'); - }, TEST_TIMEOUT); + it( + "records OK status text", + () => { + expect(readTraces()).toContain('"OK"'); + }, + TEST_TIMEOUT, + ); - it("records NOT FOUND status text", () => { - expect(readTraces()).toContain('"NOT FOUND"'); - }, TEST_TIMEOUT); + it( + "records NOT FOUND status text", + () => { + expect(readTraces()).toContain('"NOT FOUND"'); + }, + TEST_TIMEOUT, + ); }); describe("Trace Propagation", () => { - it("propagates traceparent to gRPC", () => { - const requests = readCapturedHeaders(); - const grpcCalls = requests.filter((r) => r.method === "POST" && r.traceparent !== null); - expect(grpcCalls.length).toBeGreaterThan(0); - }, TEST_TIMEOUT); + it( + "propagates traceparent to backend calls", + () => { + const requests = readCapturedHeaders(); + const backendCalls = requests.filter((r) => r.traceparent !== null); + expect(backendCalls.length).toBeGreaterThan(0); + }, + TEST_TIMEOUT, + ); - it("uses W3C traceparent format", () => { - const requests = readCapturedHeaders(); - const grpcCalls = requests.filter((r) => r.traceparent !== null); - grpcCalls.forEach((r) => expect(r.traceparent).toMatch(/^[0-9a-f]{2}-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$/)); - }, TEST_TIMEOUT); + it( + "uses W3C traceparent format", + () => { + const requests = readCapturedHeaders(); + const grpcCalls = requests.filter((r) => r.traceparent !== null); + grpcCalls.forEach((r) => expect(r.traceparent).toMatch(/^[0-9a-f]{2}-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$/)); + }, + TEST_TIMEOUT, + ); - it("uses traceparent version 00", () => { - const requests = readCapturedHeaders(); - const grpcCalls = requests.filter((r) => r.traceparent !== null); - grpcCalls.forEach((r) => expect(r.traceparent).toMatch(/^00-/)); - }, TEST_TIMEOUT); + it( + "uses traceparent version 00", + () => { + const requests = readCapturedHeaders(); + const grpcCalls = requests.filter((r) => r.traceparent !== null); + grpcCalls.forEach((r) => expect(r.traceparent).toMatch(/^00-/)); + }, + TEST_TIMEOUT, + ); - it("sets sampled flag to 01", () => { - const requests = readCapturedHeaders(); - const grpcCalls = requests.filter((r) => r.traceparent !== null); - grpcCalls.forEach((r) => expect(r.traceparent).toMatch(/-01$/)); - }, TEST_TIMEOUT); + it( + "sets sampled flag to 01", + () => { + const requests = readCapturedHeaders(); + const grpcCalls = requests.filter((r) => r.traceparent !== null); + grpcCalls.forEach((r) => expect(r.traceparent).toMatch(/-01$/)); + }, + TEST_TIMEOUT, + ); - it("maintains trace ID consistency", () => { - const requests = readCapturedHeaders(); - const grpcCalls = requests.filter((r) => r.method === "POST" && r.traceparent !== null); - const traceIds = grpcCalls.map((r) => r.traceparent!.split("-")[1]); - const uniqueIds = new Set(traceIds); - expect(uniqueIds.size).toBeLessThan(traceIds.length); - }, TEST_TIMEOUT); + it( + "maintains trace ID consistency", + () => { + const requests = readCapturedHeaders(); + const backendCalls = requests.filter((r) => r.traceparent !== null); + const traceIds = backendCalls.map((r) => r.traceparent!.split("-")[1]); + const uniqueIds = new Set(traceIds); + expect(uniqueIds.size).toBeLessThan(traceIds.length); + }, + TEST_TIMEOUT, + ); }); describe("Resource Attributes", () => { - it("contains service.name", () => { - expect(readTraces()).toContain('"zitadel-login-test"'); - }, TEST_TIMEOUT); + it( + "contains service.name", + () => { + expect(readTraces()).toContain('"zitadel-login-test"'); + }, + TEST_TIMEOUT, + ); - it("contains deployment.environment.name", () => { - expect(readTraces()).toContain('"deployment.environment.name"'); - }, TEST_TIMEOUT); + it( + "contains deployment.environment.name", + () => { + expect(readTraces()).toContain('"deployment.environment.name"'); + }, + TEST_TIMEOUT, + ); - it("contains process.runtime.name", () => { - expect(readTraces()).toContain('"nodejs"'); - }, TEST_TIMEOUT); + it( + "contains process.runtime.name", + () => { + expect(readTraces()).toContain('"nodejs"'); + }, + TEST_TIMEOUT, + ); - it("contains process.runtime.version", () => { - expect(readTraces()).toContain('"process.runtime.version"'); - }, TEST_TIMEOUT); + it( + "contains process.runtime.version", + () => { + expect(readTraces()).toContain('"process.runtime.version"'); + }, + TEST_TIMEOUT, + ); - it("contains container.id when running in container", () => { - expect(readTraces()).toContain('"container.id"'); - }, TEST_TIMEOUT); + it( + "contains container.id when running in container", + () => { + expect(readTraces()).toContain('"container.id"'); + }, + TEST_TIMEOUT, + ); - it("contains host.name", () => { - expect(readTraces()).toContain('"host.name"'); - }, TEST_TIMEOUT); + it( + "contains host.name", + () => { + expect(readTraces()).toContain('"host.name"'); + }, + TEST_TIMEOUT, + ); - it("contains host.arch", () => { - expect(readTraces()).toContain('"host.arch"'); - }, TEST_TIMEOUT); + it( + "contains host.arch", + () => { + expect(readTraces()).toContain('"host.arch"'); + }, + TEST_TIMEOUT, + ); - it("contains process.pid", () => { - expect(readTraces()).toContain('"process.pid"'); - }, TEST_TIMEOUT); + it( + "contains process.pid", + () => { + expect(readTraces()).toContain('"process.pid"'); + }, + TEST_TIMEOUT, + ); }); }); describe("Metrics", () => { describe("OTLP Export", () => { - it("exports metrics to collector", () => { - expect(readMetrics().length).toBeGreaterThan(0); - }, TEST_TIMEOUT); + it( + "exports metrics to collector", + () => { + expect(readMetrics().length).toBeGreaterThan(0); + }, + TEST_TIMEOUT, + ); - it("contains timestamps", () => { - expect(readMetrics()).toContain("timeUnixNano"); - }, TEST_TIMEOUT); + it( + "contains timestamps", + () => { + expect(readMetrics()).toContain("timeUnixNano"); + }, + TEST_TIMEOUT, + ); - it("contains data points", () => { - expect(readMetrics()).toContain("dataPoints"); - }, TEST_TIMEOUT); + it( + "contains data points", + () => { + expect(readMetrics()).toContain("dataPoints"); + }, + TEST_TIMEOUT, + ); }); describe("Prometheus Endpoint", () => { - it("returns HTTP 200", async () => { - const response = await fetch(prometheusUrl); - expect(response.status).toBe(200); - }, TEST_TIMEOUT); + it( + "returns HTTP 200", + async () => { + const response = await fetch(prometheusUrl); + expect(response.status).toBe(200); + }, + TEST_TIMEOUT, + ); - it("returns text/plain content type", async () => { - const response = await fetch(prometheusUrl); - expect(response.headers.get("content-type")).toContain("text/plain"); - }, TEST_TIMEOUT); + it( + "returns text/plain content type", + async () => { + const response = await fetch(prometheusUrl); + expect(response.headers.get("content-type")).toContain("text/plain"); + }, + TEST_TIMEOUT, + ); }); describe("Runtime Metrics", () => { - it("exposes nodejs_eventloop_utilization", async () => { - const response = await fetch(prometheusUrl); - expect(await response.text()).toContain("nodejs_eventloop_utilization"); - }, TEST_TIMEOUT); + it( + "exposes nodejs_eventloop_utilization", + async () => { + const response = await fetch(prometheusUrl); + expect(await response.text()).toContain("nodejs_eventloop_utilization"); + }, + TEST_TIMEOUT, + ); - it("exposes nodejs_eventloop_delay", async () => { - const response = await fetch(prometheusUrl); - expect(await response.text()).toContain("nodejs_eventloop_delay"); - }, TEST_TIMEOUT); + it( + "exposes nodejs_eventloop_delay", + async () => { + const response = await fetch(prometheusUrl); + expect(await response.text()).toContain("nodejs_eventloop_delay"); + }, + TEST_TIMEOUT, + ); - it("exposes v8js_memory_heap_used", async () => { - const response = await fetch(prometheusUrl); - expect(await response.text()).toContain("v8js_memory_heap_used"); - }, TEST_TIMEOUT); + it( + "exposes v8js_memory_heap_used", + async () => { + const response = await fetch(prometheusUrl); + expect(await response.text()).toContain("v8js_memory_heap_used"); + }, + TEST_TIMEOUT, + ); - it("exposes v8js_memory_heap_limit", async () => { - const response = await fetch(prometheusUrl); - expect(await response.text()).toContain("v8js_memory_heap_limit"); - }, TEST_TIMEOUT); + it( + "exposes v8js_memory_heap_limit", + async () => { + const response = await fetch(prometheusUrl); + expect(await response.text()).toContain("v8js_memory_heap_limit"); + }, + TEST_TIMEOUT, + ); - it("exposes v8js_gc_duration", async () => { - const response = await fetch(prometheusUrl); - expect(await response.text()).toContain("v8js_gc_duration"); - }, TEST_TIMEOUT); + it( + "exposes v8js_gc_duration", + async () => { + const response = await fetch(prometheusUrl); + expect(await response.text()).toContain("v8js_gc_duration"); + }, + TEST_TIMEOUT, + ); }); describe("HTTP Metrics", () => { - it("exposes http_server_duration", async () => { - const response = await fetch(prometheusUrl); - expect(await response.text()).toContain("http_server_duration"); - }, TEST_TIMEOUT); + it( + "exposes http_server_duration", + async () => { + const response = await fetch(prometheusUrl); + expect(await response.text()).toContain("http_server_duration"); + }, + TEST_TIMEOUT, + ); - it("exposes http_server_duration_sum", async () => { - const response = await fetch(prometheusUrl); - expect(await response.text()).toContain("http_server_duration_sum"); - }, TEST_TIMEOUT); + it( + "exposes http_server_duration_sum", + async () => { + const response = await fetch(prometheusUrl); + expect(await response.text()).toContain("http_server_duration_sum"); + }, + TEST_TIMEOUT, + ); - it("exposes http_server_duration_count", async () => { - const response = await fetch(prometheusUrl); - expect(await response.text()).toContain("http_server_duration_count"); - }, TEST_TIMEOUT); + it( + "exposes http_server_duration_count", + async () => { + const response = await fetch(prometheusUrl); + expect(await response.text()).toContain("http_server_duration_count"); + }, + TEST_TIMEOUT, + ); - it("exposes http_server_duration_bucket", async () => { - const response = await fetch(prometheusUrl); - expect(await response.text()).toContain("http_server_duration_bucket"); - }, TEST_TIMEOUT); + it( + "exposes http_server_duration_bucket", + async () => { + const response = await fetch(prometheusUrl); + expect(await response.text()).toContain("http_server_duration_bucket"); + }, + TEST_TIMEOUT, + ); - it("records status code 200", async () => { - const response = await fetch(prometheusUrl); - expect(await response.text()).toContain('http_status_code="200"'); - }, TEST_TIMEOUT); + it( + "records status code 200", + async () => { + const response = await fetch(prometheusUrl); + expect(await response.text()).toContain('http_status_code="200"'); + }, + TEST_TIMEOUT, + ); - it("records status code 404", async () => { - const response = await fetch(prometheusUrl); - expect(await response.text()).toContain('http_status_code="404"'); - }, TEST_TIMEOUT); + it( + "records status code 404", + async () => { + const response = await fetch(prometheusUrl); + expect(await response.text()).toContain('http_status_code="404"'); + }, + TEST_TIMEOUT, + ); - it("records HTTP GET method", async () => { - const response = await fetch(prometheusUrl); - expect(await response.text()).toContain('http_method="GET"'); - }, TEST_TIMEOUT); + it( + "records HTTP GET method", + async () => { + const response = await fetch(prometheusUrl); + expect(await response.text()).toContain('http_method="GET"'); + }, + TEST_TIMEOUT, + ); }); describe("Application Metrics", () => { - it("exposes http_server_requests_total", async () => { - const response = await fetch(prometheusUrl); - expect(await response.text()).toContain("http_server_requests_total"); - }, TEST_TIMEOUT); + it( + "exposes http_server_requests_total", + async () => { + const response = await fetch(prometheusUrl); + expect(await response.text()).toContain("http_server_requests_total"); + }, + TEST_TIMEOUT, + ); - it("exposes http_server_active_requests", async () => { - const response = await fetch(prometheusUrl); - expect(await response.text()).toContain("http_server_active_requests"); - }, TEST_TIMEOUT); + it( + "exposes http_server_active_requests", + async () => { + const response = await fetch(prometheusUrl); + expect(await response.text()).toContain("http_server_active_requests"); + }, + TEST_TIMEOUT, + ); - it("exposes login_auth_attempts_total", async () => { - const response = await fetch(prometheusUrl); - expect(await response.text()).toContain("login_auth_attempts_total"); - }, TEST_TIMEOUT); + it( + "exposes login_auth_attempts_total", + async () => { + const response = await fetch(prometheusUrl); + expect(await response.text()).toContain("login_auth_attempts_total"); + }, + TEST_TIMEOUT, + ); - it("exposes login_auth_successes_total", async () => { - const response = await fetch(prometheusUrl); - expect(await response.text()).toContain("login_auth_successes_total"); - }, TEST_TIMEOUT); + it( + "exposes login_auth_successes_total", + async () => { + const response = await fetch(prometheusUrl); + expect(await response.text()).toContain("login_auth_successes_total"); + }, + TEST_TIMEOUT, + ); - it("exposes login_session_creation_duration", async () => { - const response = await fetch(prometheusUrl); - expect(await response.text()).toContain("login_session_creation_duration"); - }, TEST_TIMEOUT); + it( + "exposes login_session_creation_duration", + async () => { + const response = await fetch(prometheusUrl); + expect(await response.text()).toContain("login_session_creation_duration"); + }, + TEST_TIMEOUT, + ); - it("exposes http_server_response_status_total", async () => { - const response = await fetch(prometheusUrl); - expect(await response.text()).toContain("http_server_response_status_total"); - }, TEST_TIMEOUT); + it( + "exposes http_server_response_status_total", + async () => { + const response = await fetch(prometheusUrl); + expect(await response.text()).toContain("http_server_response_status_total"); + }, + TEST_TIMEOUT, + ); - it("exposes http_server_request_duration_seconds", async () => { - const response = await fetch(prometheusUrl); - expect(await response.text()).toContain("http_server_request_duration_seconds"); - }, TEST_TIMEOUT); + it( + "exposes http_server_request_duration_seconds", + async () => { + const response = await fetch(prometheusUrl); + expect(await response.text()).toContain("http_server_request_duration_seconds"); + }, + TEST_TIMEOUT, + ); }); describe("Custom Test Metrics", () => { - it("exposes otel_test_requests_total", async () => { - const response = await fetch(prometheusUrl); - expect(await response.text()).toContain("otel_test_requests_total"); - }, TEST_TIMEOUT); + it( + "exposes otel_test_requests_total", + async () => { + const response = await fetch(prometheusUrl); + expect(await response.text()).toContain("otel_test_requests_total"); + }, + TEST_TIMEOUT, + ); - it("exposes otel_test_duration_seconds", async () => { - const response = await fetch(prometheusUrl); - expect(await response.text()).toContain("otel_test_duration_seconds"); - }, TEST_TIMEOUT); + it( + "exposes otel_test_duration_seconds", + async () => { + const response = await fetch(prometheusUrl); + expect(await response.text()).toContain("otel_test_duration_seconds"); + }, + TEST_TIMEOUT, + ); }); describe("Metric Labels", () => { - it("includes method label on auth metrics", async () => { - const response = await fetch(prometheusUrl); - expect(await response.text()).toContain('method="test"'); - }, TEST_TIMEOUT); + it( + "includes method label on auth metrics", + async () => { + const response = await fetch(prometheusUrl); + expect(await response.text()).toContain('method="test"'); + }, + TEST_TIMEOUT, + ); - it("includes organization label on auth metrics", async () => { - const response = await fetch(prometheusUrl); - expect(await response.text()).toContain('organization="test-org"'); - }, TEST_TIMEOUT); + it( + "includes organization label on auth metrics", + async () => { + const response = await fetch(prometheusUrl); + expect(await response.text()).toContain('organization="test-org"'); + }, + TEST_TIMEOUT, + ); - it("includes route label on request metrics", async () => { - const response = await fetch(prometheusUrl); - expect(await response.text()).toContain('route="/otel-test"'); - }, TEST_TIMEOUT); + it( + "includes route label on request metrics", + async () => { + const response = await fetch(prometheusUrl); + expect(await response.text()).toContain('route="/otel-test"'); + }, + TEST_TIMEOUT, + ); - it("includes success label on session metrics", async () => { - const response = await fetch(prometheusUrl); - expect(await response.text()).toContain('success="true"'); - }, TEST_TIMEOUT); + it( + "includes success label on session metrics", + async () => { + const response = await fetch(prometheusUrl); + expect(await response.text()).toContain('success="true"'); + }, + TEST_TIMEOUT, + ); }); describe("Resource Attributes", () => { - it("contains service.name", () => { - expect(readMetrics()).toContain('"zitadel-login-test"'); - }, TEST_TIMEOUT); + it( + "contains service.name", + () => { + expect(readMetrics()).toContain('"zitadel-login-test"'); + }, + TEST_TIMEOUT, + ); - it("contains deployment.environment.name", () => { - expect(readMetrics()).toContain('"deployment.environment.name"'); - }, TEST_TIMEOUT); + it( + "contains deployment.environment.name", + () => { + expect(readMetrics()).toContain('"deployment.environment.name"'); + }, + TEST_TIMEOUT, + ); - it("contains process.runtime.name", () => { - expect(readMetrics()).toContain('"nodejs"'); - }, TEST_TIMEOUT); + it( + "contains process.runtime.name", + () => { + expect(readMetrics()).toContain('"nodejs"'); + }, + TEST_TIMEOUT, + ); - it("contains process.runtime.version", () => { - expect(readMetrics()).toContain('"process.runtime.version"'); - }, TEST_TIMEOUT); + it( + "contains process.runtime.version", + () => { + expect(readMetrics()).toContain('"process.runtime.version"'); + }, + TEST_TIMEOUT, + ); - it("contains container.id when running in container", () => { - expect(readMetrics()).toContain('"container.id"'); - }, TEST_TIMEOUT); + it( + "contains container.id when running in container", + () => { + expect(readMetrics()).toContain('"container.id"'); + }, + TEST_TIMEOUT, + ); - it("contains host.name", () => { - expect(readMetrics()).toContain('"host.name"'); - }, TEST_TIMEOUT); + it( + "contains host.name", + () => { + expect(readMetrics()).toContain('"host.name"'); + }, + TEST_TIMEOUT, + ); - it("contains host.arch", () => { - expect(readMetrics()).toContain('"host.arch"'); - }, TEST_TIMEOUT); + it( + "contains host.arch", + () => { + expect(readMetrics()).toContain('"host.arch"'); + }, + TEST_TIMEOUT, + ); - it("contains process.pid", () => { - expect(readMetrics()).toContain('"process.pid"'); - }, TEST_TIMEOUT); + it( + "contains process.pid", + () => { + expect(readMetrics()).toContain('"process.pid"'); + }, + TEST_TIMEOUT, + ); }); }); describe("Logs", () => { describe("OTLP Export", () => { - it("exports logs to collector", () => { - expect(readLogs().length).toBeGreaterThan(0); - }, TEST_TIMEOUT); + it( + "exports logs to collector", + () => { + expect(readLogs().length).toBeGreaterThan(0); + }, + TEST_TIMEOUT, + ); - it("contains timestamps", () => { - expect(readLogs()).toContain("timeUnixNano"); - }, TEST_TIMEOUT); + it( + "contains timestamps", + () => { + expect(readLogs()).toContain("timeUnixNano"); + }, + TEST_TIMEOUT, + ); - it("contains severity", () => { - expect(readLogs()).toContain("severityNumber"); - }, TEST_TIMEOUT); + it( + "contains severity", + () => { + expect(readLogs()).toContain("severityNumber"); + }, + TEST_TIMEOUT, + ); - it("contains body", () => { - expect(readLogs()).toContain("body"); - }, TEST_TIMEOUT); + it( + "contains body", + () => { + expect(readLogs()).toContain("body"); + }, + TEST_TIMEOUT, + ); }); describe("Log Content", () => { - it("contains OTEL test log message", () => { - expect(readLogs()).toContain("OTEL test endpoint called"); - }, TEST_TIMEOUT); + it( + "contains OTEL test log message", + () => { + expect(readLogs()).toContain("OTEL test endpoint called"); + }, + TEST_TIMEOUT, + ); - it("contains test completed log message", () => { - expect(readLogs()).toContain("OTEL test completed successfully"); - }, TEST_TIMEOUT); + it( + "contains test completed log message", + () => { + expect(readLogs()).toContain("OTEL test completed successfully"); + }, + TEST_TIMEOUT, + ); - it("contains INFO severity level", () => { - const logs = readLogs(); - expect(logs).toContain('"severityText":"info"'); - }, TEST_TIMEOUT); + it( + "contains INFO severity level", + () => { + const logs = readLogs(); + expect(logs).toContain('"severityText":"info"'); + }, + TEST_TIMEOUT, + ); - it("contains severity number 9 for INFO", () => { - expect(readLogs()).toContain('"severityNumber":9'); - }, TEST_TIMEOUT); + it( + "contains severity number 9 for INFO", + () => { + expect(readLogs()).toContain('"severityNumber":9'); + }, + TEST_TIMEOUT, + ); }); describe("Log-Trace Correlation", () => { - it("includes traceId in log records", () => { - expect(readLogs()).toContain('"traceId"'); - }, TEST_TIMEOUT); + it( + "includes traceId in log records", + () => { + expect(readLogs()).toContain('"traceId"'); + }, + TEST_TIMEOUT, + ); - it("includes spanId in log records", () => { - expect(readLogs()).toContain('"spanId"'); - }, TEST_TIMEOUT); + it( + "includes spanId in log records", + () => { + expect(readLogs()).toContain('"spanId"'); + }, + TEST_TIMEOUT, + ); - it("has valid trace ID format", () => { - expect(readLogs()).toMatch(/"traceId":"[a-f0-9]{32}"/); - }, TEST_TIMEOUT); + it( + "has valid trace ID format", + () => { + expect(readLogs()).toMatch(/"traceId":"[a-f0-9]{32}"/); + }, + TEST_TIMEOUT, + ); - it("has valid span ID format", () => { - expect(readLogs()).toMatch(/"spanId":"[a-f0-9]{16}"/); - }, TEST_TIMEOUT); + it( + "has valid span ID format", + () => { + expect(readLogs()).toMatch(/"spanId":"[a-f0-9]{16}"/); + }, + TEST_TIMEOUT, + ); - it("includes trace_id attribute", () => { - expect(readLogs()).toContain('"trace_id"'); - }, TEST_TIMEOUT); + it( + "includes trace_id attribute", + () => { + expect(readLogs()).toContain('"trace_id"'); + }, + TEST_TIMEOUT, + ); - it("includes span_id attribute", () => { - expect(readLogs()).toContain('"span_id"'); - }, TEST_TIMEOUT); + it( + "includes span_id attribute", + () => { + expect(readLogs()).toContain('"span_id"'); + }, + TEST_TIMEOUT, + ); - it("includes trace_flags attribute", () => { - expect(readLogs()).toContain('"trace_flags"'); - }, TEST_TIMEOUT); + it( + "includes trace_flags attribute", + () => { + expect(readLogs()).toContain('"trace_flags"'); + }, + TEST_TIMEOUT, + ); }); describe("Log Attributes", () => { - it("includes context attribute", () => { - expect(readLogs()).toContain('"context"'); - }, TEST_TIMEOUT); + it( + "includes context attribute", + () => { + expect(readLogs()).toContain('"context"'); + }, + TEST_TIMEOUT, + ); - it("includes service attribute", () => { - expect(readLogs()).toContain('"service"'); - }, TEST_TIMEOUT); + it( + "includes service attribute", + () => { + expect(readLogs()).toContain('"service"'); + }, + TEST_TIMEOUT, + ); - it("includes timestamp attribute", () => { - expect(readLogs()).toContain('"timestamp"'); - }, TEST_TIMEOUT); + it( + "includes timestamp attribute", + () => { + expect(readLogs()).toContain('"timestamp"'); + }, + TEST_TIMEOUT, + ); - it("includes custom test attribute", () => { - expect(readLogs()).toContain('"test"'); - }, TEST_TIMEOUT); + it( + "includes custom test attribute", + () => { + expect(readLogs()).toContain('"test"'); + }, + TEST_TIMEOUT, + ); - it("includes duration attribute", () => { - expect(readLogs()).toContain('"duration"'); - }, TEST_TIMEOUT); + it( + "includes duration attribute", + () => { + expect(readLogs()).toContain('"duration"'); + }, + TEST_TIMEOUT, + ); }); describe("Resource Attributes", () => { - it("contains service.name", () => { - expect(readLogs()).toContain('"zitadel-login-test"'); - }, TEST_TIMEOUT); + it( + "contains service.name", + () => { + expect(readLogs()).toContain('"zitadel-login-test"'); + }, + TEST_TIMEOUT, + ); - it("contains deployment.environment.name", () => { - expect(readLogs()).toContain('"deployment.environment.name"'); - }, TEST_TIMEOUT); + it( + "contains deployment.environment.name", + () => { + expect(readLogs()).toContain('"deployment.environment.name"'); + }, + TEST_TIMEOUT, + ); - it("contains process.runtime.name", () => { - expect(readLogs()).toContain('"nodejs"'); - }, TEST_TIMEOUT); + it( + "contains process.runtime.name", + () => { + expect(readLogs()).toContain('"nodejs"'); + }, + TEST_TIMEOUT, + ); - it("contains process.runtime.version", () => { - expect(readLogs()).toContain('"process.runtime.version"'); - }, TEST_TIMEOUT); + it( + "contains process.runtime.version", + () => { + expect(readLogs()).toContain('"process.runtime.version"'); + }, + TEST_TIMEOUT, + ); - it("contains container.id when running in container", () => { - expect(readLogs()).toContain('"container.id"'); - }, TEST_TIMEOUT); + it( + "contains container.id when running in container", + () => { + expect(readLogs()).toContain('"container.id"'); + }, + TEST_TIMEOUT, + ); - it("contains host.name", () => { - expect(readLogs()).toContain('"host.name"'); - }, TEST_TIMEOUT); + it( + "contains host.name", + () => { + expect(readLogs()).toContain('"host.name"'); + }, + TEST_TIMEOUT, + ); - it("contains host.arch", () => { - expect(readLogs()).toContain('"host.arch"'); - }, TEST_TIMEOUT); + it( + "contains host.arch", + () => { + expect(readLogs()).toContain('"host.arch"'); + }, + TEST_TIMEOUT, + ); - it("contains process.pid", () => { - expect(readLogs()).toContain('"process.pid"'); - }, TEST_TIMEOUT); + it( + "contains process.pid", + () => { + expect(readLogs()).toContain('"process.pid"'); + }, + TEST_TIMEOUT, + ); }); }); }); @@ -812,15 +1282,13 @@ describe("OpenTelemetry Disabled", () => { mockZitadel = await new GenericContainer("node:20-alpine") .withNetwork(network) .withNetworkAliases("mock-zitadel") - .withCopyFilesToContainer([ - { source: path.join(TEST_DIR, "mock-zitadel-server.js"), target: "/app/server.js" }, - ]) + .withCopyFilesToContainer([{ source: path.join(TEST_DIR, "mock-zitadel-server.js"), target: "/app/server.js" }]) .withEnvironment({ PORT: "8080", }) .withCommand(["node", "/app/server.js"]) - .withExposedPorts(8080, 7432) - .withWaitStrategy(Wait.forLogMessage("Mock Zitadel HTTP/2 server listening")) + .withExposedPorts(8080) + .withWaitStrategy(Wait.forLogMessage("Mock Zitadel server listening")) .start(); loginApp = await new GenericContainer(LOGIN_IMAGE_TAG) @@ -846,42 +1314,62 @@ describe("OpenTelemetry Disabled", () => { }, DOCKER_TIMEOUT); describe("Application Health", () => { - it("starts successfully with OTEL_SDK_DISABLED=true", async () => { - const response = await fetch(`${appUrl}/ui/v2/login/healthy`); - expect(response.ok).toBe(true); - }, TEST_TIMEOUT); + it( + "starts successfully with OTEL_SDK_DISABLED=true", + async () => { + const response = await fetch(`${appUrl}/ui/v2/login/healthy`); + expect(response.ok).toBe(true); + }, + TEST_TIMEOUT, + ); - it("responds to health checks", async () => { - const response = await fetch(`${appUrl}/ui/v2/login/healthy`); - expect(response.status).toBe(200); - }, TEST_TIMEOUT); + it( + "responds to health checks", + async () => { + const response = await fetch(`${appUrl}/ui/v2/login/healthy`); + expect(response.status).toBe(200); + }, + TEST_TIMEOUT, + ); }); describe("Metrics Endpoint", () => { - it("metrics endpoint is not available when OTEL is disabled", async () => { - const metricsUrl = `http://${loginApp.getHost()}:${loginApp.getMappedPort(9464)}/metrics`; - try { - await fetch(metricsUrl); - expect.fail("Metrics endpoint should not be available"); - } catch (error) { - expect(error).toBeDefined(); - } - }, TEST_TIMEOUT); + it( + "metrics endpoint is not available when OTEL is disabled", + async () => { + const metricsUrl = `http://${loginApp.getHost()}:${loginApp.getMappedPort(9464)}/metrics`; + try { + await fetch(metricsUrl); + expect.fail("Metrics endpoint should not be available"); + } catch (error) { + expect(error).toBeDefined(); + } + }, + TEST_TIMEOUT, + ); }); describe("Logging", () => { - it("outputs logs to console via Winston", async () => { - await fetch(`${appUrl}/ui/v2/login/healthy`); - await new Promise((resolve) => setTimeout(resolve, 1000)); - const logOutput = await getContainerLogs(loginApp); - expect(logOutput.length).toBeGreaterThan(0); - }, TEST_TIMEOUT); + it( + "outputs logs to console via Winston", + async () => { + await fetch(`${appUrl}/ui/v2/login/healthy`); + await new Promise((resolve) => setTimeout(resolve, 1000)); + const logOutput = await getContainerLogs(loginApp); + expect(logOutput.length).toBeGreaterThan(0); + }, + TEST_TIMEOUT, + ); - it("does not contain OTEL SDK initialization logs", async () => { - const logOutput = await getContainerLogs(loginApp); - expect(logOutput).not.toContain("OpenTelemetry SDK started"); - expect(logOutput).not.toContain("Exporter"); - }, TEST_TIMEOUT); + it( + "does not contain OTEL SDK initialization logs", + async () => { + const logOutput = await getContainerLogs(loginApp); + expect(logOutput).not.toContain("OpenTelemetry SDK started"); + expect(logOutput).not.toContain("Exporter"); + }, + TEST_TIMEOUT, + ); }); }); @@ -899,15 +1387,13 @@ describe("Log Level Configuration", () => { mockZitadel = await new GenericContainer("node:20-alpine") .withNetwork(network) .withNetworkAliases("mock-zitadel") - .withCopyFilesToContainer([ - { source: path.join(TEST_DIR, "mock-zitadel-server.js"), target: "/app/server.js" }, - ]) + .withCopyFilesToContainer([{ source: path.join(TEST_DIR, "mock-zitadel-server.js"), target: "/app/server.js" }]) .withEnvironment({ PORT: "8080", }) .withCommand(["node", "/app/server.js"]) - .withExposedPorts(8080, 7432) - .withWaitStrategy(Wait.forLogMessage("Mock Zitadel HTTP/2 server listening")) + .withExposedPorts(8080) + .withWaitStrategy(Wait.forLogMessage("Mock Zitadel server listening")) .start(); loginApp = await new GenericContainer(LOGIN_IMAGE_TAG) @@ -937,15 +1423,23 @@ describe("Log Level Configuration", () => { await network?.stop().catch(() => {}); }, DOCKER_TIMEOUT); - it("starts successfully", async () => { - const response = await fetch(`${appUrl}/ui/v2/login/healthy`); - expect(response.ok).toBe(true); - }, TEST_TIMEOUT); + it( + "starts successfully", + async () => { + const response = await fetch(`${appUrl}/ui/v2/login/healthy`); + expect(response.ok).toBe(true); + }, + TEST_TIMEOUT, + ); - it("includes info level logs in console", async () => { - const logOutput = await getContainerLogs(loginApp); - expect(logOutput).toContain('"level":"info"'); - }, TEST_TIMEOUT); + it( + "includes info level logs in console", + async () => { + const logOutput = await getContainerLogs(loginApp); + expect(logOutput).toContain('"level":"info"'); + }, + TEST_TIMEOUT, + ); }); describe("LOG_LEVEL=warn", () => { @@ -960,15 +1454,13 @@ describe("Log Level Configuration", () => { mockZitadel = await new GenericContainer("node:20-alpine") .withNetwork(network) .withNetworkAliases("mock-zitadel") - .withCopyFilesToContainer([ - { source: path.join(TEST_DIR, "mock-zitadel-server.js"), target: "/app/server.js" }, - ]) + .withCopyFilesToContainer([{ source: path.join(TEST_DIR, "mock-zitadel-server.js"), target: "/app/server.js" }]) .withEnvironment({ PORT: "8080", }) .withCommand(["node", "/app/server.js"]) - .withExposedPorts(8080, 7432) - .withWaitStrategy(Wait.forLogMessage("Mock Zitadel HTTP/2 server listening")) + .withExposedPorts(8080) + .withWaitStrategy(Wait.forLogMessage("Mock Zitadel server listening")) .start(); loginApp = await new GenericContainer(LOGIN_IMAGE_TAG) @@ -997,20 +1489,32 @@ describe("Log Level Configuration", () => { await network?.stop().catch(() => {}); }, DOCKER_TIMEOUT); - it("starts successfully", async () => { - const response = await fetch(`${appUrl}/ui/v2/login/healthy`); - expect(response.ok).toBe(true); - }, TEST_TIMEOUT); + it( + "starts successfully", + async () => { + const response = await fetch(`${appUrl}/ui/v2/login/healthy`); + expect(response.ok).toBe(true); + }, + TEST_TIMEOUT, + ); - it("does not include info level logs in console", async () => { - const logOutput = await getContainerLogs(loginApp); - expect(logOutput).not.toContain('"level":"info"'); - }, TEST_TIMEOUT); + it( + "does not include info level logs in console", + async () => { + const logOutput = await getContainerLogs(loginApp); + expect(logOutput).not.toContain('"level":"info"'); + }, + TEST_TIMEOUT, + ); - it("does not include debug level logs in console", async () => { - const logOutput = await getContainerLogs(loginApp); - expect(logOutput).not.toContain('"level":"debug"'); - }, TEST_TIMEOUT); + it( + "does not include debug level logs in console", + async () => { + const logOutput = await getContainerLogs(loginApp); + expect(logOutput).not.toContain('"level":"debug"'); + }, + TEST_TIMEOUT, + ); }); }); @@ -1051,15 +1555,13 @@ describe("Log Level Configuration", () => { mockZitadel = await new GenericContainer("node:20-alpine") .withNetwork(network) .withNetworkAliases("mock-zitadel") - .withCopyFilesToContainer([ - { source: path.join(TEST_DIR, "mock-zitadel-server.js"), target: "/app/server.js" }, - ]) + .withCopyFilesToContainer([{ source: path.join(TEST_DIR, "mock-zitadel-server.js"), target: "/app/server.js" }]) .withEnvironment({ PORT: "8080", }) .withCommand(["node", "/app/server.js"]) - .withExposedPorts(8080, 7432) - .withWaitStrategy(Wait.forLogMessage("Mock Zitadel HTTP/2 server listening")) + .withExposedPorts(8080) + .withWaitStrategy(Wait.forLogMessage("Mock Zitadel server listening")) .start(); loginApp = await new GenericContainer(LOGIN_IMAGE_TAG) @@ -1096,25 +1598,41 @@ describe("Log Level Configuration", () => { await network?.stop().catch(() => {}); }, DOCKER_TIMEOUT); - it("starts successfully", async () => { - const response = await fetch(`${appUrl}/ui/v2/login/healthy`); - expect(response.ok).toBe(true); - }, TEST_TIMEOUT); + it( + "starts successfully", + async () => { + const response = await fetch(`${appUrl}/ui/v2/login/healthy`); + expect(response.ok).toBe(true); + }, + TEST_TIMEOUT, + ); - it("exports logs to OTLP collector", () => { - const logs = fs.readFileSync(path.join(logOutputDir, "logs.json"), "utf-8"); - expect(logs.length).toBeGreaterThan(0); - }, TEST_TIMEOUT); + it( + "exports logs to OTLP collector", + () => { + const logs = fs.readFileSync(path.join(logOutputDir, "logs.json"), "utf-8"); + expect(logs.length).toBeGreaterThan(0); + }, + TEST_TIMEOUT, + ); - it("includes info level logs in OTLP export", () => { - const logs = fs.readFileSync(path.join(logOutputDir, "logs.json"), "utf-8"); - expect(logs).toContain('"severityText":"info"'); - }, TEST_TIMEOUT); + it( + "includes info level logs in OTLP export", + () => { + const logs = fs.readFileSync(path.join(logOutputDir, "logs.json"), "utf-8"); + expect(logs).toContain('"severityText":"info"'); + }, + TEST_TIMEOUT, + ); - it("includes info level logs in console", async () => { - const logOutput = await getContainerLogs(loginApp); - expect(logOutput).toContain('"level":"info"'); - }, TEST_TIMEOUT); + it( + "includes info level logs in console", + async () => { + const logOutput = await getContainerLogs(loginApp); + expect(logOutput).toContain('"level":"info"'); + }, + TEST_TIMEOUT, + ); }); describe("LOG_LEVEL=warn", () => { @@ -1151,15 +1669,13 @@ describe("Log Level Configuration", () => { mockZitadel = await new GenericContainer("node:20-alpine") .withNetwork(network) .withNetworkAliases("mock-zitadel") - .withCopyFilesToContainer([ - { source: path.join(TEST_DIR, "mock-zitadel-server.js"), target: "/app/server.js" }, - ]) + .withCopyFilesToContainer([{ source: path.join(TEST_DIR, "mock-zitadel-server.js"), target: "/app/server.js" }]) .withEnvironment({ PORT: "8080", }) .withCommand(["node", "/app/server.js"]) - .withExposedPorts(8080, 7432) - .withWaitStrategy(Wait.forLogMessage("Mock Zitadel HTTP/2 server listening")) + .withExposedPorts(8080) + .withWaitStrategy(Wait.forLogMessage("Mock Zitadel server listening")) .start(); loginApp = await new GenericContainer(LOGIN_IMAGE_TAG) @@ -1194,36 +1710,56 @@ describe("Log Level Configuration", () => { await network?.stop().catch(() => {}); }, DOCKER_TIMEOUT); - it("starts successfully", async () => { - const response = await fetch(`${appUrl}/ui/v2/login/healthy`); - expect(response.ok).toBe(true); - }, TEST_TIMEOUT); + it( + "starts successfully", + async () => { + const response = await fetch(`${appUrl}/ui/v2/login/healthy`); + expect(response.ok).toBe(true); + }, + TEST_TIMEOUT, + ); - it("does not include info level logs in console", async () => { - const logOutput = await getContainerLogs(loginApp); - expect(logOutput).not.toContain('"level":"info"'); - }, TEST_TIMEOUT); + it( + "does not include info level logs in console", + async () => { + const logOutput = await getContainerLogs(loginApp); + expect(logOutput).not.toContain('"level":"info"'); + }, + TEST_TIMEOUT, + ); - it("does not include debug level logs in console", async () => { - const logOutput = await getContainerLogs(loginApp); - expect(logOutput).not.toContain('"level":"debug"'); - }, TEST_TIMEOUT); + it( + "does not include debug level logs in console", + async () => { + const logOutput = await getContainerLogs(loginApp); + expect(logOutput).not.toContain('"level":"debug"'); + }, + TEST_TIMEOUT, + ); - it("does not export info level logs to OTLP", () => { - const logsPath = path.join(logOutputDir, "logs.json"); - if (fs.existsSync(logsPath)) { - const logs = fs.readFileSync(logsPath, "utf-8"); - expect(logs).not.toContain('"severityText":"info"'); - } - }, TEST_TIMEOUT); + it( + "does not export info level logs to OTLP", + () => { + const logsPath = path.join(logOutputDir, "logs.json"); + if (fs.existsSync(logsPath)) { + const logs = fs.readFileSync(logsPath, "utf-8"); + expect(logs).not.toContain('"severityText":"info"'); + } + }, + TEST_TIMEOUT, + ); - it("does not export debug level logs to OTLP", () => { - const logsPath = path.join(logOutputDir, "logs.json"); - if (fs.existsSync(logsPath)) { - const logs = fs.readFileSync(logsPath, "utf-8"); - expect(logs).not.toContain('"severityText":"debug"'); - } - }, TEST_TIMEOUT); + it( + "does not export debug level logs to OTLP", + () => { + const logsPath = path.join(logOutputDir, "logs.json"); + if (fs.existsSync(logsPath)) { + const logs = fs.readFileSync(logsPath, "utf-8"); + expect(logs).not.toContain('"severityText":"debug"'); + } + }, + TEST_TIMEOUT, + ); }); }); }); diff --git a/apps/login/package.json b/apps/login/package.json index 93fbb4381..827ff2510 100644 --- a/apps/login/package.json +++ b/apps/login/package.json @@ -52,6 +52,8 @@ "server-only": "^0.0.1", "tinycolor2": "^1.6.0", "uuid": "^13.0.0", + "@connectrpc/connect-node": "^2.1.1", + "@connectrpc/connect": "^2.1.1", "winston": "^3.17.0" }, "devDependencies": { diff --git a/apps/login/src/lib/grpc/interceptors/otel.test.ts b/apps/login/src/lib/grpc/interceptors/otel.test.ts index 6986ef8bc..29e7bc835 100644 --- a/apps/login/src/lib/grpc/interceptors/otel.test.ts +++ b/apps/login/src/lib/grpc/interceptors/otel.test.ts @@ -1,7 +1,11 @@ +import { Code, ConnectError, createRouterTransport } from "@connectrpc/connect"; import { SpanKind, SpanStatusCode } from "@opentelemetry/api"; import { resourceFromAttributes } from "@opentelemetry/resources"; import { InMemorySpanExporter, SimpleSpanProcessor } from "@opentelemetry/sdk-trace-base"; import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node"; +import { createClientFor } from "@zitadel/client"; +import { SessionService } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; +import { UserService } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; import { afterAll, beforeEach, describe, expect, it } from "vitest"; import { otelGrpcInterceptor } from "./otel"; @@ -18,31 +22,37 @@ describe("otelGrpcInterceptor", () => { beforeEach(() => exporter.reset()); it("injects traceparent header into gRPC requests", async () => { - const capturedHeaders: Record = {}; + let capturedHeaders: Headers | undefined; - await otelGrpcInterceptor(async () => ({ success: true }))({ - service: { typeName: "zitadel.session.v2.SessionService" }, - method: { name: "CreateSession" }, - url: "https://api.zitadel.example.com/zitadel.session.v2/CreateSession", - header: { - set: (key: string, value: string) => { - capturedHeaders[key.toLowerCase()] = value; - }, + const mockTransport = createRouterTransport(() => {}, { + transport: { + interceptors: [ + otelGrpcInterceptor, + (next) => (req) => { + capturedHeaders = req.header; + return next(req); + }, + ], }, }); - expect(capturedHeaders["traceparent"]).toMatch(/^[0-9a-f]{2}-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$/); + const client = createClientFor(SessionService)(mockTransport); + await expect(client.createSession({})).rejects.toThrow(); + expect(capturedHeaders!.get("traceparent")!).toMatch(/^[0-9a-f]{2}-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$/); }); it("creates client spans with RPC attributes", async () => { - await otelGrpcInterceptor(async () => ({ user: { id: "123" } }))({ - service: { typeName: "zitadel.user.v2.UserService" }, - method: { name: "GetUserByID" }, - url: "https://api.zitadel.example.com/zitadel.user.v2/GetUserByID", - header: { set: () => {} }, + const mockTransport = createRouterTransport(() => {}, { + transport: { + interceptors: [otelGrpcInterceptor], + }, }); + const client = createClientFor(UserService)(mockTransport); + await expect(client.getUserByID({})).rejects.toThrow(); + const spans = exporter.getFinishedSpans(); + expect(spans).toHaveLength(1); expect(spans[0]).toMatchObject({ name: "zitadel.user.v2.UserService/GetUserByID", @@ -51,151 +61,125 @@ describe("otelGrpcInterceptor", () => { "rpc.system": "grpc", "rpc.service": "zitadel.user.v2.UserService", "rpc.method": "GetUserByID", - "server.address": "api.zitadel.example.com", + "server.address": "in-memory", }, }); }); it("records error spans with gRPC status codes", async () => { - const error = Object.assign(new Error("Session not found"), { code: 5 }); + const mockTransport = createRouterTransport( + ({ service }) => { + service(SessionService, { + deleteSession: () => { + throw new ConnectError("Session not found", Code.NotFound); + }, + }); + }, + { + transport: { + interceptors: [otelGrpcInterceptor], + }, + }, + ); - await expect( - otelGrpcInterceptor(async () => { - throw error; - })({ - service: { typeName: "zitadel.session.v2.SessionService" }, - method: { name: "DeleteSession" }, - url: "https://api.zitadel.example.com", - header: { set: () => {} }, - }), - ).rejects.toThrow("Session not found"); + const client = createClientFor(SessionService)(mockTransport); + await expect(client.deleteSession({})).rejects.toThrow(); const spans = exporter.getFinishedSpans(); expect(spans).toHaveLength(1); expect(spans[0]).toMatchObject({ name: "zitadel.session.v2.SessionService/DeleteSession", - status: { code: SpanStatusCode.ERROR, message: "Session not found" }, + status: { + code: SpanStatusCode.ERROR, + // https://github.com/connectrpc/connect-es/blob/beb1864a45786f01c8ecb76c93a08ac14483885a/packages/connect/src/connect-error.ts#L32 + // code prefix gets added by connect + message: "[not_found] Session not found", + }, attributes: { "rpc.grpc.status_code": 5 }, }); expect(spans[0].events).toHaveLength(1); expect(spans[0].events[0].name).toBe("exception"); }); - it("handles missing service/method gracefully", async () => { - await otelGrpcInterceptor(async () => ({ ok: true }))({ - url: "https://api.zitadel.example.com", - header: { set: () => {} }, - }); - - const spans = exporter.getFinishedSpans(); - expect(spans).toHaveLength(1); - expect(spans[0]).toMatchObject({ - name: "unknown/unknown", - attributes: { "rpc.service": "unknown", "rpc.method": "unknown" }, - }); - }); - it("returns the response from the next handler", async () => { - const expectedResponse = { data: "test-data", items: [1, 2, 3] }; + const expectedResponse = { + details: {}, + sessionId: "test-session-id", + sessionToken: "test-token", + }; - const result = await otelGrpcInterceptor(async () => expectedResponse)({ - service: { typeName: "TestService" }, - method: { name: "TestMethod" }, - url: "https://test.example.com", - header: { set: () => {} }, - }); + const mockTransport = createRouterTransport( + ({ service }) => { + service(SessionService, { + createSession: () => expectedResponse, + }); + }, + { + transport: { + interceptors: [otelGrpcInterceptor], + }, + }, + ); - expect(result).toEqual(expectedResponse); + const client = createClientFor(SessionService)(mockTransport); + const result = await client.createSession({}); + expect(result).toMatchObject(expectedResponse); }); - it("records error spans without gRPC status code", async () => { - const error = new Error("Network failure"); + it("handles in-memory transport URL gracefully", async () => { + const mockTransport = createRouterTransport(() => {}, { + transport: { + interceptors: [otelGrpcInterceptor], + }, + }); - await expect( - otelGrpcInterceptor(async () => { - throw error; - })({ - service: { typeName: "zitadel.user.v2.UserService" }, - method: { name: "ListUsers" }, - url: "https://api.zitadel.example.com", - header: { set: () => {} }, - }), - ).rejects.toThrow("Network failure"); + const client = createClientFor(SessionService)(mockTransport); + await expect(client.createSession({})).rejects.toThrow(); + + const spans = exporter.getFinishedSpans(); + expect(spans).toHaveLength(1); + expect(spans[0].attributes["server.address"]).toBeDefined(); + }); + + it("sets correct service and method names from protobuf definitions", async () => { + const mockTransport = createRouterTransport(() => {}, { + transport: { + interceptors: [otelGrpcInterceptor], + }, + }); + + const client = createClientFor(SessionService)(mockTransport); + await expect(client.deleteSession({})).rejects.toThrow(); const spans = exporter.getFinishedSpans(); expect(spans).toHaveLength(1); expect(spans[0]).toMatchObject({ - name: "zitadel.user.v2.UserService/ListUsers", - status: { code: SpanStatusCode.ERROR, message: "Network failure" }, - }); - expect(spans[0].attributes).not.toHaveProperty("rpc.grpc.status_code"); - }); - - it("handles malformed URL gracefully", async () => { - await otelGrpcInterceptor(async () => ({ ok: true }))({ - service: { typeName: "TestService" }, - method: { name: "TestMethod" }, - url: "not-a-valid-url", - header: { set: () => {} }, - }); - - const spans = exporter.getFinishedSpans(); - expect(spans).toHaveLength(1); - expect(spans[0].attributes["server.address"]).toBe("unknown"); - }); - - it("handles missing URL gracefully", async () => { - await otelGrpcInterceptor(async () => ({ ok: true }))({ - service: { typeName: "TestService" }, - method: { name: "TestMethod" }, - header: { set: () => {} }, - }); - - const spans = exporter.getFinishedSpans(); - expect(spans).toHaveLength(1); - expect(spans[0].attributes["server.address"]).toBe("unknown"); - }); - - it("handles empty string URL gracefully", async () => { - await otelGrpcInterceptor(async () => ({ ok: true }))({ - service: { typeName: "TestService" }, - method: { name: "TestMethod" }, - url: "", - header: { set: () => {} }, - }); - - const spans = exporter.getFinishedSpans(); - expect(spans).toHaveLength(1); - expect(spans[0].attributes["server.address"]).toBe("unknown"); - }); - - it("uses empty strings as-is for service/method names", async () => { - await otelGrpcInterceptor(async () => ({ ok: true }))({ - service: { typeName: "" }, - method: { name: "" }, - url: "https://api.example.com", - header: { set: () => {} }, - }); - - const spans = exporter.getFinishedSpans(); - expect(spans).toHaveLength(1); - expect(spans[0]).toMatchObject({ - name: "/", - attributes: { "rpc.service": "", "rpc.method": "" }, + name: "zitadel.session.v2.SessionService/DeleteSession", + attributes: { + "rpc.service": "zitadel.session.v2.SessionService", + "rpc.method": "DeleteSession", + }, }); }); it("handles non-Error exceptions", async () => { - await expect( - otelGrpcInterceptor(async () => { - throw "string error"; - })({ - service: { typeName: "TestService" }, - method: { name: "TestMethod" }, - url: "https://api.example.com", - header: { set: () => {} }, - }), - ).rejects.toBe("string error"); + const mockTransport = createRouterTransport( + ({ service }) => { + service(SessionService, { + createSession: () => { + throw "string error"; + }, + }); + }, + { + transport: { + interceptors: [otelGrpcInterceptor], + }, + }, + ); + + const client = createClientFor(SessionService)(mockTransport); + await expect(client.createSession({})).rejects.toThrow(); const spans = exporter.getFinishedSpans(); expect(spans).toHaveLength(1); diff --git a/apps/login/src/lib/grpc/interceptors/otel.ts b/apps/login/src/lib/grpc/interceptors/otel.ts index 703e6ba3f..ec2d32ba2 100644 --- a/apps/login/src/lib/grpc/interceptors/otel.ts +++ b/apps/login/src/lib/grpc/interceptors/otel.ts @@ -19,8 +19,8 @@ * ``` */ +import { ConnectError, Interceptor } from "@connectrpc/connect"; import { context, propagation, SpanKind, SpanStatusCode, trace } from "@opentelemetry/api"; -import { GrpcError, Interceptor } from "./types"; const TRACER_NAME = "zitadel-login-grpc" as const; @@ -28,10 +28,7 @@ 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 | undefined): string { - if (!url) { - return "unknown"; - } +function parseHostname(url: string): string { try { return new URL(url).hostname; } catch { @@ -44,8 +41,8 @@ function parseHostname(url: string | undefined): string { * client span, injects trace context headers for distributed tracing, and * records any errors that occur during the call. */ -export function otelGrpcInterceptor(next: Parameters[0]): ReturnType { - return async function tracedCall(req) { +export const otelGrpcInterceptor: Interceptor = (next) => + async function tracedCall(req) { const serviceName: string = req.service?.typeName ?? "unknown"; const methodName: string = req.method?.name ?? "unknown"; @@ -70,12 +67,12 @@ export function otelGrpcInterceptor(next: Parameters[0]): ReturnTyp const response = await next(req); span.setStatus({ code: SpanStatusCode.OK }); return response; - } catch (err: unknown) { + } catch (err) { const exception = err instanceof Error ? err : new Error(String(err)); span.recordException(exception); - const grpcError = err as GrpcError; - if (grpcError.code !== undefined) { - span.setAttribute("rpc.grpc.status_code", grpcError.code); + + if (exception instanceof ConnectError) { + span.setAttribute("rpc.grpc.status_code", exception.code); } span.setStatus({ code: SpanStatusCode.ERROR, @@ -88,4 +85,3 @@ export function otelGrpcInterceptor(next: Parameters[0]): ReturnTyp }, ); }; -} diff --git a/apps/login/src/lib/grpc/interceptors/types.ts b/apps/login/src/lib/grpc/interceptors/types.ts deleted file mode 100644 index e62b30c7b..000000000 --- a/apps/login/src/lib/grpc/interceptors/types.ts +++ /dev/null @@ -1,16 +0,0 @@ -export interface GrpcRequestHeader { - readonly set: (key: string, value: string) => void; -} - -export interface GrpcRequest { - readonly service?: { readonly typeName: string }; - readonly method?: { readonly name: string }; - readonly url?: string; - readonly header: GrpcRequestHeader; -} - -export interface GrpcError extends Error { - readonly code?: number; -} - -export type Interceptor = (next: (req: GrpcRequest) => Promise) => (req: GrpcRequest) => Promise; diff --git a/apps/login/src/lib/server/loginname.ts b/apps/login/src/lib/server/loginname.ts index dfbb56bd8..af469c414 100644 --- a/apps/login/src/lib/server/loginname.ts +++ b/apps/login/src/lib/server/loginname.ts @@ -529,7 +529,6 @@ export async function sendLoginname(command: SendLoginnameCommand) { // user not found, check if IDPs are available when local auth is not allowed if (!effectiveLoginSettings?.allowLocalAuthentication) { - console.log("redirecting to IDP (register allowed, password not allowed)"); logger.debug("redirecting to IDP (register allowed, password not allowed)"); const resp = await redirectUserToIDP(undefined, discoveredOrganization); if (resp) { diff --git a/apps/login/src/lib/verify-helper.test.ts b/apps/login/src/lib/verify-helper.test.ts index 501230c19..9c0a000d5 100644 --- a/apps/login/src/lib/verify-helper.test.ts +++ b/apps/login/src/lib/verify-helper.test.ts @@ -2,7 +2,6 @@ import crypto from "crypto"; import { cookies } from "next/headers"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { getFingerprintIdCookie } from "./fingerprint"; -import { shouldEnforceMFA } from "./verify-helper"; import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; import { @@ -11,6 +10,7 @@ import { checkMFAFactors, checkPasswordChangeRequired, checkUserVerification, + shouldEnforceMFA, } from "./verify-helper"; // Mock function to create timestamps - following the same pattern as session.test.ts diff --git a/apps/login/src/lib/zitadel.ts b/apps/login/src/lib/zitadel.ts index 0d6c1336d..7001be94b 100644 --- a/apps/login/src/lib/zitadel.ts +++ b/apps/login/src/lib/zitadel.ts @@ -1,5 +1,5 @@ +import { createConnectTransport } from "@connectrpc/connect-node"; import { Client, create, Duration } from "@zitadel/client"; -import { createServerTransport as libCreateServerTransport } from "@zitadel/client/node"; import { makeReqCtx } from "@zitadel/client/v2"; import { IdentityProviderService } from "@zitadel/proto/zitadel/idp/v2/idp_service_pb"; import { OrganizationSchema, TextQueryMethod } from "@zitadel/proto/zitadel/object/v2/object_pb"; @@ -32,9 +32,10 @@ import { import { getTranslations } from "next-intl/server"; import { unstable_cacheLife as cacheLife } from "next/cache"; import { getUserAgent } from "./fingerprint"; -import { otelGrpcInterceptor } from "./grpc/interceptors/otel"; import { createLogger } from "./logger"; +import { otelGrpcInterceptor } from "@/lib/grpc/interceptors/otel"; +import { Interceptor } from "@connectrpc/connect"; import { createServiceForHost } from "./service"; const logger = createLogger("zitadel"); @@ -1243,46 +1244,46 @@ export type WithServiceConfig = T & { }; export function createServerTransport(token: string, serviceConfig: ServiceConfig) { - // Build interceptors list - OTEL interceptor is always included for trace propagation - const interceptors: any[] = [otelGrpcInterceptor]; + const authorizationInterceptor: Interceptor = (next) => (req) => { + if (!req.header.get("Authorization")) { + req.header.set("Authorization", `Bearer ${token}`); + } + return next(req); + }; - // Add custom headers interceptor if needed - if (process.env.CUSTOM_REQUEST_HEADERS || serviceConfig.instanceHost || serviceConfig.publicHost) { - interceptors.push((next: any) => { - return (req: any) => { - // Apply headers from serviceConfig - if (serviceConfig.instanceHost) { - req.header.set("x-zitadel-instance-host", serviceConfig.instanceHost); - } - if (serviceConfig.publicHost) { - req.header.set("x-zitadel-public-host", serviceConfig.publicHost); + const headerInterceptor: Interceptor = (next) => (req) => { + // Apply headers from serviceConfig + if (serviceConfig.instanceHost) { + req.header.set("x-zitadel-instance-host", serviceConfig.instanceHost); + } + if (serviceConfig.publicHost) { + req.header.set("x-zitadel-public-host", serviceConfig.publicHost); + } + + // Apply headers from CUSTOM_REQUEST_HEADERS environment variable + if (process.env.CUSTOM_REQUEST_HEADERS) { + process.env.CUSTOM_REQUEST_HEADERS.split(",").forEach((header) => { + const kv = header.indexOf(":"); + if (kv > 0) { + const key = header.slice(0, kv).trim(); + const value = header.slice(kv + 1).trim(); + if (value) { + req.header.set(key, value); + } else { + req.header.delete(key); + } + } else { + logger.warn("Skipping malformed header", { header }); } + }); + } - // Apply headers from CUSTOM_REQUEST_HEADERS environment variable - if (process.env.CUSTOM_REQUEST_HEADERS) { - process.env.CUSTOM_REQUEST_HEADERS.split(",").forEach((header) => { - const kv = header.indexOf(":"); - if (kv > 0) { - const key = header.slice(0, kv).trim(); - const value = header.slice(kv + 1).trim(); - if (value) { - req.header.set(key, value); - } else { - req.header.delete(key); - } - } else { - logger.warn("Skipping malformed header", { header }); - } - }); - } + return next(req); + }; - return next(req); - }; - }); - } - - return libCreateServerTransport(token, { + return createConnectTransport({ + httpVersion: "1.1", baseUrl: serviceConfig.baseUrl, - interceptors, + interceptors: [otelGrpcInterceptor, authorizationInterceptor, headerInterceptor], }); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a68fc113a..beaa7bdc4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -190,6 +190,12 @@ importers: apps/login: dependencies: + '@connectrpc/connect': + specifier: ^2.1.1 + version: 2.1.1(@bufbuild/protobuf@2.11.0) + '@connectrpc/connect-node': + specifier: ^2.1.1 + version: 2.1.1(@bufbuild/protobuf@2.11.0)(@connectrpc/connect@2.1.1(@bufbuild/protobuf@2.11.0)) '@headlessui/react': specifier: ^2.2.9 version: 2.2.9(react-dom@19.2.4(react@19.2.4))(react@19.2.4)