diff --git a/apps/web-next/src/server/bind-production.ts b/apps/web-next/src/server/bind-production.ts index 5195e42..03cf0e2 100644 --- a/apps/web-next/src/server/bind-production.ts +++ b/apps/web-next/src/server/bind-production.ts @@ -54,7 +54,7 @@ export async function bindAllProduction(): Promise { bound = true; const { tracer, logger } = resolveInstrumentation(); // Rule 0 const resolvedConfig = await config; - bindProductionAuth(resolvedConfig); + bindProductionAuth(resolvedConfig, tracer, logger); // Phase E task 19 bindProductionBlog(resolvedConfig, tracer, logger); // Phase E task 18 bindProductionMarketingPages(resolvedConfig); bindProductionNavigation(resolvedConfig); @@ -70,7 +70,7 @@ export async function bindAllDevSeed(): Promise { if (bound) return; bound = true; const { tracer, logger } = resolveInstrumentation(); // Rule 0 - await bindDevSeedAuth(); + await bindDevSeedAuth(tracer, logger); // Phase E task 19 await bindDevSeedBlog(tracer, logger); // Phase E task 18 await bindDevSeedMarketingPages(); await bindDevSeedNavigation(); diff --git a/packages/auth/src/di/bind-dev-seed.test.ts b/packages/auth/src/di/bind-dev-seed.test.ts index a9d0ae2..79898b0 100644 --- a/packages/auth/src/di/bind-dev-seed.test.ts +++ b/packages/auth/src/di/bind-dev-seed.test.ts @@ -1,11 +1,14 @@ import "reflect-metadata"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { NoopTracer, NoopLogger } from "@repo/core-shared/instrumentation"; import { bindDevSeedAuth } from "@/di/bind-dev-seed"; import { authContainer } from "@/di/container"; import { AUTH_SYMBOLS } from "@/di/symbols"; import { MockUsersRepository } from "@/infrastructure/repositories/users.repository.mock"; import type { IUsersRepository } from "@/application/repositories/users.repository.interface"; +const noop = { tracer: new NoopTracer(), logger: new NoopLogger() }; + describe("bindDevSeedAuth", () => { // Each test starts from the default empty-mock binding and tears down // afterwards so the global authContainer state stays clean for siblings. @@ -28,7 +31,7 @@ describe("bindDevSeedAuth", () => { }); it("populates the repository with the dev users", async () => { - await bindDevSeedAuth(); + await bindDevSeedAuth(noop.tracer, noop.logger); const repo = authContainer.get( AUTH_SYMBOLS.IUsersRepository, @@ -41,7 +44,7 @@ describe("bindDevSeedAuth", () => { }); it("seeds alice reachable by username", async () => { - await bindDevSeedAuth(); + await bindDevSeedAuth(noop.tracer, noop.logger); const repo = authContainer.get( AUTH_SYMBOLS.IUsersRepository, @@ -54,13 +57,13 @@ describe("bindDevSeedAuth", () => { }); it("is idempotent — calling twice rebuilds a fresh populated repo", async () => { - await bindDevSeedAuth(); + await bindDevSeedAuth(noop.tracer, noop.logger); const before = authContainer.get( AUTH_SYMBOLS.IUsersRepository, ); const beforeAlice = await before.getUserByUsername("alice"); - await bindDevSeedAuth(); + await bindDevSeedAuth(noop.tracer, noop.logger); const after = authContainer.get( AUTH_SYMBOLS.IUsersRepository, ); diff --git a/packages/auth/src/di/bind-dev-seed.ts b/packages/auth/src/di/bind-dev-seed.ts index 13f9388..6a17844 100644 --- a/packages/auth/src/di/bind-dev-seed.ts +++ b/packages/auth/src/di/bind-dev-seed.ts @@ -1,8 +1,21 @@ +import { + withSpan, + INSTRUMENTATION_SYMBOLS, + type ITracer, + type ILogger, +} from "@repo/core-shared/instrumentation"; import { authContainer } from "./container.js"; import { AUTH_SYMBOLS } from "./symbols.js"; import { MockUsersRepository } from "../infrastructure/repositories/users.repository.mock.js"; import { buildDevUsers } from "../__seeds__/dev.js"; +import { signInUseCase } from "../application/use-cases/sign-in.use-case.js"; +import { signUpUseCase } from "../application/use-cases/sign-up.use-case.js"; +import { signOutUseCase } from "../application/use-cases/sign-out.use-case.js"; +import { signInController } from "../interface-adapters/controllers/sign-in.controller.js"; +import { signUpController } from "../interface-adapters/controllers/sign-up.controller.js"; +import { signOutController } from "../interface-adapters/controllers/sign-out.controller.js"; import type { IUsersRepository } from "../application/repositories/users.repository.interface.js"; +import type { IAuthenticationService } from "../application/services/authentication.service.interface.js"; /** * Replace the default mock with a populated one for dev mode + storybook. @@ -17,12 +30,22 @@ import type { IUsersRepository } from "../application/repositories/users.reposit * Idempotent: safe to call multiple times; each call rebuilds a fresh * populated repo and rebinds the symbol. */ -export async function bindDevSeedAuth(): Promise { +export async function bindDevSeedAuth(tracer: ITracer, logger: ILogger): Promise { + // Bind shared instrumentation into feature container + if (authContainer.isBound(INSTRUMENTATION_SYMBOLS.TRACER)) { + authContainer.unbind(INSTRUMENTATION_SYMBOLS.TRACER); + } + if (authContainer.isBound(INSTRUMENTATION_SYMBOLS.LOGGER)) { + authContainer.unbind(INSTRUMENTATION_SYMBOLS.LOGGER); + } + authContainer.bind(INSTRUMENTATION_SYMBOLS.TRACER).toConstantValue(tracer); + authContainer.bind(INSTRUMENTATION_SYMBOLS.LOGGER).toConstantValue(logger); + if (authContainer.isBound(AUTH_SYMBOLS.IUsersRepository)) { authContainer.unbind(AUTH_SYMBOLS.IUsersRepository); } - const repo = new MockUsersRepository([]); + const repo = new MockUsersRepository([], tracer, logger); for (const user of buildDevUsers()) { await repo.createUser(user); } @@ -30,4 +53,66 @@ export async function bindDevSeedAuth(): Promise { authContainer .bind(AUTH_SYMBOLS.IUsersRepository) .toConstantValue(repo); + + // Need auth service from container for use cases + const authService = authContainer.get(AUTH_SYMBOLS.IAuthenticationService); + + // Wrap use cases + controllers identically to bind-production + const wrappedSignIn = withSpan( + tracer, + { name: "auth.signIn", op: "use-case" }, + signInUseCase(repo, authService), + ); + const wrappedSignUp = withSpan( + tracer, + { name: "auth.signUp", op: "use-case" }, + signUpUseCase(repo, authService), + ); + const wrappedSignOut = withSpan( + tracer, + { name: "auth.signOut", op: "use-case" }, + signOutUseCase(authService), + ); + + for (const sym of [ + AUTH_SYMBOLS.ISignInUseCase, + AUTH_SYMBOLS.ISignUpUseCase, + AUTH_SYMBOLS.ISignOutUseCase, + AUTH_SYMBOLS.ISignInController, + AUTH_SYMBOLS.ISignUpController, + AUTH_SYMBOLS.ISignOutController, + ]) { + if (authContainer.isBound(sym)) authContainer.unbind(sym); + } + authContainer.bind(AUTH_SYMBOLS.ISignInUseCase).toConstantValue(wrappedSignIn); + authContainer.bind(AUTH_SYMBOLS.ISignUpUseCase).toConstantValue(wrappedSignUp); + authContainer.bind(AUTH_SYMBOLS.ISignOutUseCase).toConstantValue(wrappedSignOut); + + authContainer + .bind(AUTH_SYMBOLS.ISignInController) + .toConstantValue( + withSpan( + tracer, + { name: "auth.signIn", op: "controller" }, + signInController(wrappedSignIn), + ), + ); + authContainer + .bind(AUTH_SYMBOLS.ISignUpController) + .toConstantValue( + withSpan( + tracer, + { name: "auth.signUp", op: "controller" }, + signUpController(wrappedSignUp), + ), + ); + authContainer + .bind(AUTH_SYMBOLS.ISignOutController) + .toConstantValue( + withSpan( + tracer, + { name: "auth.signOut", op: "controller" }, + signOutController(wrappedSignOut), + ), + ); } diff --git a/packages/auth/src/di/bind-production.ts b/packages/auth/src/di/bind-production.ts index 0c2de46..35a1ab6 100644 --- a/packages/auth/src/di/bind-production.ts +++ b/packages/auth/src/di/bind-production.ts @@ -1,28 +1,119 @@ import type { SanitizedConfig } from "payload"; +import { + withSpan, + INSTRUMENTATION_SYMBOLS, + type ITracer, + type ILogger, +} from "@repo/core-shared/instrumentation"; import { authContainer } from "./container"; import { AUTH_SYMBOLS } from "./symbols"; import { UsersRepository } from "../infrastructure/repositories/users.repository"; import { AuthenticationService } from "../infrastructure/services/authentication.service"; +import { signInUseCase } from "../application/use-cases/sign-in.use-case"; +import { signUpUseCase } from "../application/use-cases/sign-up.use-case"; +import { signOutUseCase } from "../application/use-cases/sign-out.use-case"; +import { signInController } from "../interface-adapters/controllers/sign-in.controller"; +import { signUpController } from "../interface-adapters/controllers/sign-up.controller"; +import { signOutController } from "../interface-adapters/controllers/sign-out.controller"; import type { IUsersRepository } from "../application/repositories/users.repository.interface"; import type { IAuthenticationService } from "../application/services/authentication.service.interface"; let bound = false; -export function bindProductionAuth(config: SanitizedConfig): void { +export function bindProductionAuth( + config: SanitizedConfig, + tracer: ITracer, + logger: ILogger, +): void { if (bound) return; bound = true; + // Bind shared instrumentation into feature container + if (authContainer.isBound(INSTRUMENTATION_SYMBOLS.TRACER)) { + authContainer.unbind(INSTRUMENTATION_SYMBOLS.TRACER); + } + if (authContainer.isBound(INSTRUMENTATION_SYMBOLS.LOGGER)) { + authContainer.unbind(INSTRUMENTATION_SYMBOLS.LOGGER); + } + authContainer.bind(INSTRUMENTATION_SYMBOLS.TRACER).toConstantValue(tracer); + authContainer.bind(INSTRUMENTATION_SYMBOLS.LOGGER).toConstantValue(logger); + + // Real repositories if (authContainer.isBound(AUTH_SYMBOLS.IUsersRepository)) { authContainer.unbind(AUTH_SYMBOLS.IUsersRepository); } - authContainer - .bind(AUTH_SYMBOLS.IUsersRepository) - .toConstantValue(new UsersRepository(config)); + const repo = new UsersRepository(config, tracer, logger); + authContainer.bind(AUTH_SYMBOLS.IUsersRepository).toConstantValue(repo); if (authContainer.isBound(AUTH_SYMBOLS.IAuthenticationService)) { authContainer.unbind(AUTH_SYMBOLS.IAuthenticationService); } + const authService = new AuthenticationService(config); authContainer .bind(AUTH_SYMBOLS.IAuthenticationService) - .toConstantValue(new AuthenticationService(config)); + .toConstantValue(authService); + + // Use cases — wrapped with span at bind time + const wrappedSignIn = withSpan( + tracer, + { name: "auth.signIn", op: "use-case" }, + signInUseCase(repo, authService), + ); + const wrappedSignUp = withSpan( + tracer, + { name: "auth.signUp", op: "use-case" }, + signUpUseCase(repo, authService), + ); + const wrappedSignOut = withSpan( + tracer, + { name: "auth.signOut", op: "use-case" }, + signOutUseCase(authService), + ); + + for (const sym of [ + AUTH_SYMBOLS.ISignInUseCase, + AUTH_SYMBOLS.ISignUpUseCase, + AUTH_SYMBOLS.ISignOutUseCase, + ]) { + if (authContainer.isBound(sym)) authContainer.unbind(sym); + } + authContainer.bind(AUTH_SYMBOLS.ISignInUseCase).toConstantValue(wrappedSignIn); + authContainer.bind(AUTH_SYMBOLS.ISignUpUseCase).toConstantValue(wrappedSignUp); + authContainer.bind(AUTH_SYMBOLS.ISignOutUseCase).toConstantValue(wrappedSignOut); + + // Controllers — wrapped with span at bind time + for (const sym of [ + AUTH_SYMBOLS.ISignInController, + AUTH_SYMBOLS.ISignUpController, + AUTH_SYMBOLS.ISignOutController, + ]) { + if (authContainer.isBound(sym)) authContainer.unbind(sym); + } + authContainer + .bind(AUTH_SYMBOLS.ISignInController) + .toConstantValue( + withSpan( + tracer, + { name: "auth.signIn", op: "controller" }, + signInController(wrappedSignIn), + ), + ); + authContainer + .bind(AUTH_SYMBOLS.ISignUpController) + .toConstantValue( + withSpan( + tracer, + { name: "auth.signUp", op: "controller" }, + signUpController(wrappedSignUp), + ), + ); + authContainer + .bind(AUTH_SYMBOLS.ISignOutController) + .toConstantValue( + withSpan( + tracer, + { name: "auth.signOut", op: "controller" }, + signOutController(wrappedSignOut), + ), + ); } diff --git a/packages/auth/src/infrastructure/repositories/users.repository.mock.ts b/packages/auth/src/infrastructure/repositories/users.repository.mock.ts index 122dcc3..0f0417a 100644 --- a/packages/auth/src/infrastructure/repositories/users.repository.mock.ts +++ b/packages/auth/src/infrastructure/repositories/users.repository.mock.ts @@ -1,5 +1,11 @@ import "reflect-metadata"; import { injectable } from "inversify"; +import { + NoopTracer, + NoopLogger, + type ITracer, + type ILogger, +} from "@repo/core-shared/instrumentation"; import type { IUsersRepository } from "../../application/repositories/users.repository.interface"; import type { User } from "../../entities/models/user"; @@ -12,21 +18,54 @@ const DEFAULT_SEED: User[] = [ @injectable() export class MockUsersRepository implements IUsersRepository { private _users: User[]; + private tracer: ITracer; + private logger: ILogger; - constructor(initialUsers: User[] = DEFAULT_SEED) { + constructor( + initialUsers: User[] = DEFAULT_SEED, + tracer: ITracer = new NoopTracer(), + logger: ILogger = new NoopLogger(), + ) { this._users = [...initialUsers]; + this.tracer = tracer; + this.logger = logger; + void this.logger; // currently unused; reserved for future mock-thrown captures } async getUser(id: string): Promise { - return this._users.find((u) => u.id === id); + return this.tracer.startSpan( + { name: "users.getUser", op: "repository", attributes: { id } }, + async (span) => { + const found = this._users.find((u) => u.id === id); + span.setAttribute("found", Boolean(found)); + return found; + }, + ); } async getUserByUsername(username: string): Promise { - return this._users.find((u) => u.username === username); + return this.tracer.startSpan( + { + name: "users.getUserByUsername", + op: "repository", + attributes: { emailDomain: username.includes("@") ? (username.split("@")[1] ?? "(invalid)") : username }, + }, + async (span) => { + const found = this._users.find((u) => u.username === username); + span.setAttribute("found", Boolean(found)); + return found; + }, + ); } async createUser(input: User): Promise { - this._users.push(input); - return input; + return this.tracer.startSpan( + { name: "users.createUser", op: "repository", attributes: { id: input.id } }, + async (span) => { + this._users.push(input); + span.setAttribute("created", true); + return input; + }, + ); } } diff --git a/packages/auth/src/infrastructure/repositories/users.repository.span.test.ts b/packages/auth/src/infrastructure/repositories/users.repository.span.test.ts new file mode 100644 index 0000000..f0fac69 --- /dev/null +++ b/packages/auth/src/infrastructure/repositories/users.repository.span.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from "vitest"; +import { RecordingTracer, RecordingLogger } from "@repo/core-testing/instrumentation"; +import { MockUsersRepository } from "@/infrastructure/repositories/users.repository.mock"; + +// Mock repo also wraps in spans (R42); easier to assert without booting Payload. +describe("MockUsersRepository emits spans (R42)", () => { + it("getUser emits one span with op='repository'", async () => { + const tracer = new RecordingTracer(); + const logger = new RecordingLogger(); + const repo = new MockUsersRepository([], tracer, logger); + await repo.getUser("missing"); + expect(tracer.spans).toHaveLength(1); + expect(tracer.spans[0]).toMatchObject({ + name: "users.getUser", + op: "repository", + }); + expect(tracer.spans[0]!.attributes.id).toBe("missing"); + expect(tracer.spans[0]!.attributes.found).toBe(false); + }); + + it("getUserByUsername emits a span with emailDomain attribute", async () => { + const tracer = new RecordingTracer(); + const repo = new MockUsersRepository( + [{ id: "1", username: "alice", passwordHash: "hash" }], + tracer, + ); + await repo.getUserByUsername("alice"); + expect(tracer.findSpan("users.getUserByUsername")).toBeDefined(); + expect(tracer.findSpan("users.getUserByUsername")!.attributes.found).toBe(true); + }); + + it("createUser records created=true", async () => { + const tracer = new RecordingTracer(); + const repo = new MockUsersRepository([], tracer); + await repo.createUser({ id: "u1", username: "charlie", passwordHash: "hash" }); + expect(tracer.findSpan("users.createUser")).toBeDefined(); + expect(tracer.findSpan("users.createUser")!.attributes.created).toBe(true); + }); +}); diff --git a/packages/auth/src/infrastructure/repositories/users.repository.ts b/packages/auth/src/infrastructure/repositories/users.repository.ts index 5751b23..dd23fb0 100644 --- a/packages/auth/src/infrastructure/repositories/users.repository.ts +++ b/packages/auth/src/infrastructure/repositories/users.repository.ts @@ -1,44 +1,122 @@ import { getPayload } from "payload"; import type { SanitizedConfig } from "payload"; +import { + NoopTracer, + NoopLogger, + type ITracer, + type ILogger, +} from "@repo/core-shared/instrumentation"; import type { IUsersRepository } from "../../application/repositories/users.repository.interface"; import { type User } from "../../entities/models/user"; +const FEATURE = "auth" as const; +const REPO = "users" as const; + export class UsersRepository implements IUsersRepository { - constructor(private config: SanitizedConfig) {} + private config: SanitizedConfig; + private tracer: ITracer; + private logger: ILogger; + + constructor( + config: SanitizedConfig, + tracer: ITracer = new NoopTracer(), + logger: ILogger = new NoopLogger(), + ) { + this.config = config; + this.tracer = tracer; + this.logger = logger; + } async getUser(id: string): Promise { - const payload = await getPayload({ config: this.config }); - const result = await payload.findByID({ - collection: "users", - id, - overrideAccess: true, - }); - return result ? this.toDomain(result as Record) : undefined; + return this.tracer.startSpan( + { name: "users.getUser", op: "repository", attributes: { id } }, + async (span) => { + try { + const payload = await getPayload({ config: this.config }); + const result = await payload.findByID({ + collection: "users", + id, + overrideAccess: true, + }); + const found = Boolean(result); + span.setAttribute("found", found); + return result ? this.toDomain(result as Record) : undefined; + } catch (err) { + if ( + err && + typeof err === "object" && + "status" in err && + (err as { status: unknown }).status === 404 + ) { + span.setAttribute("found", false); + return undefined; + } + this.logger.captureException(err, { + tags: { feature: FEATURE, repo: REPO, method: "getUser" }, + }); + span.setStatus("error", err instanceof Error ? err.message : String(err)); + throw err; + } + }, + ); } async getUserByUsername(username: string): Promise { - const payload = await getPayload({ config: this.config }); - const { docs } = await payload.find({ - collection: "users", - where: { username: { equals: username } }, - limit: 1, - overrideAccess: true, - }); - return docs[0] ? this.toDomain(docs[0] as Record) : undefined; + return this.tracer.startSpan( + { + name: "users.getUserByUsername", + op: "repository", + attributes: { emailDomain: username.includes("@") ? (username.split("@")[1] ?? "(invalid)") : username }, + }, + async (span) => { + try { + const payload = await getPayload({ config: this.config }); + const { docs } = await payload.find({ + collection: "users", + where: { username: { equals: username } }, + limit: 1, + overrideAccess: true, + }); + const doc = docs[0]; + span.setAttribute("found", Boolean(doc)); + return doc ? this.toDomain(doc as Record) : undefined; + } catch (err) { + this.logger.captureException(err, { + tags: { feature: FEATURE, repo: REPO, method: "getUserByUsername" }, + }); + span.setStatus("error", err instanceof Error ? err.message : String(err)); + throw err; + } + }, + ); } async createUser(input: User): Promise { - const payload = await getPayload({ config: this.config }); - const created = await payload.create({ - collection: "users", - data: { - id: input.id, - username: input.username, - passwordHash: input.passwordHash, + return this.tracer.startSpan( + { name: "users.createUser", op: "repository", attributes: { id: input.id } }, + async (span) => { + try { + const payload = await getPayload({ config: this.config }); + const created = await payload.create({ + collection: "users", + data: { + id: input.id, + username: input.username, + passwordHash: input.passwordHash, + }, + overrideAccess: true, + }); + span.setAttribute("created", true); + return this.toDomain(created as Record); + } catch (err) { + this.logger.captureException(err, { + tags: { feature: FEATURE, repo: REPO, method: "createUser" }, + }); + span.setStatus("error", err instanceof Error ? err.message : String(err)); + throw err; + } }, - overrideAccess: true, - }); - return this.toDomain(created as Record); + ); } private toDomain(doc: Record): User {