From e14a23dccd6484fff8345aeaed4e87e42a5b90a4 Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Thu, 7 May 2026 17:59:14 +0200 Subject: [PATCH] =?UTF-8?q?feat(navigation):=20wire=20instrumentation=20?= =?UTF-8?q?=E2=80=94=20header=20repo=20spans=20+=20getHeader=20withSpan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- apps/web-next/src/server/bind-production.ts | 4 +- .../navigation/src/di/bind-dev-seed.test.ts | 11 +-- packages/navigation/src/di/bind-dev-seed.ts | 51 +++++++++++++- packages/navigation/src/di/bind-production.ts | 56 +++++++++++++++- .../repositories/header.repository.mock.ts | 25 ++++++- .../header.repository.span.test.ts | 19 ++++++ .../repositories/header.repository.ts | 67 ++++++++++++++----- 7 files changed, 202 insertions(+), 31 deletions(-) create mode 100644 packages/navigation/src/infrastructure/repositories/header.repository.span.test.ts diff --git a/apps/web-next/src/server/bind-production.ts b/apps/web-next/src/server/bind-production.ts index ffcd7fe..afb8ebe 100644 --- a/apps/web-next/src/server/bind-production.ts +++ b/apps/web-next/src/server/bind-production.ts @@ -57,7 +57,7 @@ export async function bindAllProduction(): Promise { bindProductionAuth(resolvedConfig, tracer, logger); // Phase E task 19 bindProductionBlog(resolvedConfig, tracer, logger); // Phase E task 18 bindProductionMarketingPages(resolvedConfig, tracer, logger); // Phase E task 20 - bindProductionNavigation(resolvedConfig); + bindProductionNavigation(resolvedConfig, tracer, logger); // Phase E task 21 bindProductionMedia(resolvedConfig); } @@ -73,7 +73,7 @@ export async function bindAllDevSeed(): Promise { await bindDevSeedAuth(tracer, logger); // Phase E task 19 await bindDevSeedBlog(tracer, logger); // Phase E task 18 await bindDevSeedMarketingPages(tracer, logger); // Phase E task 20 - await bindDevSeedNavigation(); + await bindDevSeedNavigation(tracer, logger); // Phase E task 21 await bindDevSeedMedia(); } diff --git a/packages/navigation/src/di/bind-dev-seed.test.ts b/packages/navigation/src/di/bind-dev-seed.test.ts index 4bfb1b4..c1b7655 100644 --- a/packages/navigation/src/di/bind-dev-seed.test.ts +++ b/packages/navigation/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 { bindDevSeedNavigation } from "@/di/bind-dev-seed"; import { navigationContainer } from "@/di/container"; import { NAVIGATION_SYMBOLS } from "@/di/symbols"; import { MockHeaderRepository } from "@/infrastructure/repositories/header.repository.mock"; import type { IHeaderRepository } from "@/application/repositories/header.repository.interface"; +const noop = { tracer: new NoopTracer(), logger: new NoopLogger() }; + describe("bindDevSeedNavigation", () => { // Each test starts from the default mock binding and tears down afterwards // so the global navigationContainer state stays clean for siblings. @@ -28,7 +31,7 @@ describe("bindDevSeedNavigation", () => { }); it("populates the header repository with the dev header", async () => { - await bindDevSeedNavigation(); + await bindDevSeedNavigation(noop.tracer, noop.logger); const repo = navigationContainer.get( NAVIGATION_SYMBOLS.IHeaderRepository, @@ -39,7 +42,7 @@ describe("bindDevSeedNavigation", () => { }); it("seeds a header with a non-empty items array", async () => { - await bindDevSeedNavigation(); + await bindDevSeedNavigation(noop.tracer, noop.logger); const repo = navigationContainer.get( NAVIGATION_SYMBOLS.IHeaderRepository, @@ -53,13 +56,13 @@ describe("bindDevSeedNavigation", () => { }); it("is idempotent — calling twice rebuilds a fresh populated repo", async () => { - await bindDevSeedNavigation(); + await bindDevSeedNavigation(noop.tracer, noop.logger); const before = navigationContainer.get( NAVIGATION_SYMBOLS.IHeaderRepository, ); const beforeHeader = await before.getHeader(); - await bindDevSeedNavigation(); + await bindDevSeedNavigation(noop.tracer, noop.logger); const after = navigationContainer.get( NAVIGATION_SYMBOLS.IHeaderRepository, ); diff --git a/packages/navigation/src/di/bind-dev-seed.ts b/packages/navigation/src/di/bind-dev-seed.ts index 626963d..933be81 100644 --- a/packages/navigation/src/di/bind-dev-seed.ts +++ b/packages/navigation/src/di/bind-dev-seed.ts @@ -1,7 +1,15 @@ +import { + withSpan, + INSTRUMENTATION_SYMBOLS, + type ITracer, + type ILogger, +} from "@repo/core-shared/instrumentation"; import { navigationContainer } from "./container.js"; import { NAVIGATION_SYMBOLS } from "./symbols.js"; import { MockHeaderRepository } from "../infrastructure/repositories/header.repository.mock.js"; import { buildDevHeader } from "../__seeds__/dev.js"; +import { getHeaderUseCase } from "../application/use-cases/get-header.use-case.js"; +import { getHeaderController } from "../interface-adapters/controllers/get-header.controller.js"; import type { IHeaderRepository } from "../application/repositories/header.repository.interface.js"; /** @@ -15,12 +23,49 @@ import type { IHeaderRepository } from "../application/repositories/header.repos * Idempotent: safe to call multiple times; each call rebuilds a fresh * populated repo and rebinds the symbol. */ -export async function bindDevSeedNavigation(): Promise { +export async function bindDevSeedNavigation(tracer: ITracer, logger: ILogger): Promise { + // Bind shared instrumentation into feature container + if (navigationContainer.isBound(INSTRUMENTATION_SYMBOLS.TRACER)) { + navigationContainer.unbind(INSTRUMENTATION_SYMBOLS.TRACER); + } + if (navigationContainer.isBound(INSTRUMENTATION_SYMBOLS.LOGGER)) { + navigationContainer.unbind(INSTRUMENTATION_SYMBOLS.LOGGER); + } + navigationContainer.bind(INSTRUMENTATION_SYMBOLS.TRACER).toConstantValue(tracer); + navigationContainer.bind(INSTRUMENTATION_SYMBOLS.LOGGER).toConstantValue(logger); + if (navigationContainer.isBound(NAVIGATION_SYMBOLS.IHeaderRepository)) { navigationContainer.unbind(NAVIGATION_SYMBOLS.IHeaderRepository); } - + const repo = new MockHeaderRepository(buildDevHeader(), tracer, logger); navigationContainer .bind(NAVIGATION_SYMBOLS.IHeaderRepository) - .toConstantValue(new MockHeaderRepository(buildDevHeader())); + .toConstantValue(repo); + + // Wrap use case + controller identically to bind-production + const wrappedGetHeader = withSpan( + tracer, + { name: "navigation.getHeader", op: "use-case" }, + getHeaderUseCase(repo), + ); + + for (const sym of [ + NAVIGATION_SYMBOLS.IGetHeaderUseCase, + NAVIGATION_SYMBOLS.IGetHeaderController, + ]) { + if (navigationContainer.isBound(sym)) navigationContainer.unbind(sym); + } + navigationContainer + .bind(NAVIGATION_SYMBOLS.IGetHeaderUseCase) + .toConstantValue(wrappedGetHeader); + + navigationContainer + .bind(NAVIGATION_SYMBOLS.IGetHeaderController) + .toConstantValue( + withSpan( + tracer, + { name: "navigation.getHeader", op: "controller" }, + getHeaderController(wrappedGetHeader), + ), + ); } diff --git a/packages/navigation/src/di/bind-production.ts b/packages/navigation/src/di/bind-production.ts index 61a6b1a..322fbff 100644 --- a/packages/navigation/src/di/bind-production.ts +++ b/packages/navigation/src/di/bind-production.ts @@ -1,13 +1,65 @@ import type { SanitizedConfig } from "payload"; +import { + withSpan, + INSTRUMENTATION_SYMBOLS, + type ITracer, + type ILogger, +} from "@repo/core-shared/instrumentation"; import { navigationContainer } from "./container"; import { NAVIGATION_SYMBOLS } from "./symbols"; import { HeaderRepository } from "../infrastructure/repositories/header.repository"; +import { getHeaderUseCase } from "../application/use-cases/get-header.use-case"; +import { getHeaderController } from "../interface-adapters/controllers/get-header.controller"; -export function bindProductionNavigation(config: SanitizedConfig): void { +export function bindProductionNavigation( + config: SanitizedConfig, + tracer: ITracer, + logger: ILogger, +): void { + // Bind shared instrumentation into feature container + if (navigationContainer.isBound(INSTRUMENTATION_SYMBOLS.TRACER)) { + navigationContainer.unbind(INSTRUMENTATION_SYMBOLS.TRACER); + } + if (navigationContainer.isBound(INSTRUMENTATION_SYMBOLS.LOGGER)) { + navigationContainer.unbind(INSTRUMENTATION_SYMBOLS.LOGGER); + } + navigationContainer.bind(INSTRUMENTATION_SYMBOLS.TRACER).toConstantValue(tracer); + navigationContainer.bind(INSTRUMENTATION_SYMBOLS.LOGGER).toConstantValue(logger); + + // Real repository if (navigationContainer.isBound(NAVIGATION_SYMBOLS.IHeaderRepository)) { navigationContainer.unbind(NAVIGATION_SYMBOLS.IHeaderRepository); } + const repo = new HeaderRepository(config, tracer, logger); navigationContainer .bind(NAVIGATION_SYMBOLS.IHeaderRepository) - .toConstantValue(new HeaderRepository(config)); + .toConstantValue(repo); + + // Use case — wrapped with span at bind time + const wrappedGetHeader = withSpan( + tracer, + { name: "navigation.getHeader", op: "use-case" }, + getHeaderUseCase(repo), + ); + + if (navigationContainer.isBound(NAVIGATION_SYMBOLS.IGetHeaderUseCase)) { + navigationContainer.unbind(NAVIGATION_SYMBOLS.IGetHeaderUseCase); + } + navigationContainer + .bind(NAVIGATION_SYMBOLS.IGetHeaderUseCase) + .toConstantValue(wrappedGetHeader); + + // Controller — wrapped with span at bind time + if (navigationContainer.isBound(NAVIGATION_SYMBOLS.IGetHeaderController)) { + navigationContainer.unbind(NAVIGATION_SYMBOLS.IGetHeaderController); + } + navigationContainer + .bind(NAVIGATION_SYMBOLS.IGetHeaderController) + .toConstantValue( + withSpan( + tracer, + { name: "navigation.getHeader", op: "controller" }, + getHeaderController(wrappedGetHeader), + ), + ); } diff --git a/packages/navigation/src/infrastructure/repositories/header.repository.mock.ts b/packages/navigation/src/infrastructure/repositories/header.repository.mock.ts index 32d0986..b112dcf 100644 --- a/packages/navigation/src/infrastructure/repositories/header.repository.mock.ts +++ b/packages/navigation/src/infrastructure/repositories/header.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 { IHeaderRepository } from "../../application/repositories/header.repository.interface"; import type { Header, HeaderItem } from "../../entities/models/header"; @@ -13,12 +19,27 @@ const DEFAULT_ITEMS: HeaderItem[] = [ @injectable() export class MockHeaderRepository implements IHeaderRepository { private readonly data: Header; + private tracer: ITracer; + private logger: ILogger; - constructor(initialData?: Header) { + constructor( + initialData?: Header, + tracer: ITracer = new NoopTracer(), + logger: ILogger = new NoopLogger(), + ) { this.data = initialData ?? { items: DEFAULT_ITEMS }; + this.tracer = tracer; + this.logger = logger; + void this.logger; // currently unused; reserved for future mock-thrown captures } async getHeader(): Promise
{ - return this.data; + return this.tracer.startSpan( + { name: "header.getHeader", op: "repository", attributes: {} }, + async (span) => { + span.setAttribute("itemCount", this.data.items.length); + return this.data; + }, + ); } } diff --git a/packages/navigation/src/infrastructure/repositories/header.repository.span.test.ts b/packages/navigation/src/infrastructure/repositories/header.repository.span.test.ts new file mode 100644 index 0000000..1b4d2bb --- /dev/null +++ b/packages/navigation/src/infrastructure/repositories/header.repository.span.test.ts @@ -0,0 +1,19 @@ +import { describe, it, expect } from "vitest"; +import { RecordingTracer, RecordingLogger } from "@repo/core-testing/instrumentation"; +import { MockHeaderRepository } from "@/infrastructure/repositories/header.repository.mock"; + +// Mock repo also wraps in spans (R42). +describe("MockHeaderRepository emits spans (R42)", () => { + it("getHeader emits one span with op='repository'", async () => { + const tracer = new RecordingTracer(); + const logger = new RecordingLogger(); + const repo = new MockHeaderRepository(undefined, tracer, logger); + await repo.getHeader(); + expect(tracer.spans).toHaveLength(1); + expect(tracer.spans[0]).toMatchObject({ + name: "header.getHeader", + op: "repository", + }); + expect(typeof tracer.spans[0]!.attributes.itemCount).toBe("number"); + }); +}); diff --git a/packages/navigation/src/infrastructure/repositories/header.repository.ts b/packages/navigation/src/infrastructure/repositories/header.repository.ts index e1eef87..7063257 100644 --- a/packages/navigation/src/infrastructure/repositories/header.repository.ts +++ b/packages/navigation/src/infrastructure/repositories/header.repository.ts @@ -2,6 +2,12 @@ import "reflect-metadata"; import { injectable } from "inversify"; import { getPayload } from "payload"; import type { SanitizedConfig } from "payload"; +import { + NoopTracer, + NoopLogger, + type ITracer, + type ILogger, +} from "@repo/core-shared/instrumentation"; import type { IHeaderRepository } from "../../application/repositories/header.repository.interface"; import type { Header, HeaderItem } from "../../entities/models/header"; @@ -15,34 +21,59 @@ type PayloadHeaderGlobal = { }> | null; }; +const FEATURE = "navigation" as const; +const REPO = "header" as const; + @injectable() export class HeaderRepository implements IHeaderRepository { private config: SanitizedConfig; + private tracer: ITracer; + private logger: ILogger; - constructor(config: SanitizedConfig) { + constructor( + config: SanitizedConfig, + tracer: ITracer = new NoopTracer(), + logger: ILogger = new NoopLogger(), + ) { this.config = config; + this.tracer = tracer; + this.logger = logger; } async getHeader(): Promise
{ - const payload = await getPayload({ config: this.config }); - const doc = (await payload.findGlobal({ - slug: "header", - overrideAccess: true, - })) as PayloadHeaderGlobal; + return this.tracer.startSpan( + { name: "header.getHeader", op: "repository", attributes: {} }, + async (span) => { + try { + const payload = await getPayload({ config: this.config }); + const doc = (await payload.findGlobal({ + slug: "header", + overrideAccess: true, + })) as PayloadHeaderGlobal; - const logoId = - typeof doc.logo === "object" && doc.logo !== null - ? String(doc.logo.id) - : doc.logo != null - ? String(doc.logo) - : undefined; + const logoId = + typeof doc.logo === "object" && doc.logo !== null + ? String(doc.logo.id) + : doc.logo != null + ? String(doc.logo) + : undefined; - const items: HeaderItem[] = (doc.items ?? []).map((item) => ({ - label: item.label ?? "", - href: item.href ?? "", - external: item.external ?? false, - })); + const items: HeaderItem[] = (doc.items ?? []).map((item) => ({ + label: item.label ?? "", + href: item.href ?? "", + external: item.external ?? false, + })); - return { logoId, items }; + span.setAttribute("itemCount", items.length); + return { logoId, items }; + } catch (err) { + this.logger.captureException(err, { + tags: { feature: FEATURE, repo: REPO, method: "getHeader" }, + }); + span.setStatus("error", err instanceof Error ? err.message : String(err)); + throw err; + } + }, + ); } }