feat(navigation): wire instrumentation — header repo spans + getHeader withSpan

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-07 17:59:14 +02:00
parent 404cb07e79
commit e14a23dccd
7 changed files with 202 additions and 31 deletions

View File

@@ -57,7 +57,7 @@ export async function bindAllProduction(): Promise<void> {
bindProductionAuth(resolvedConfig, tracer, logger); // Phase E task 19 bindProductionAuth(resolvedConfig, tracer, logger); // Phase E task 19
bindProductionBlog(resolvedConfig, tracer, logger); // Phase E task 18 bindProductionBlog(resolvedConfig, tracer, logger); // Phase E task 18
bindProductionMarketingPages(resolvedConfig, tracer, logger); // Phase E task 20 bindProductionMarketingPages(resolvedConfig, tracer, logger); // Phase E task 20
bindProductionNavigation(resolvedConfig); bindProductionNavigation(resolvedConfig, tracer, logger); // Phase E task 21
bindProductionMedia(resolvedConfig); bindProductionMedia(resolvedConfig);
} }
@@ -73,7 +73,7 @@ export async function bindAllDevSeed(): Promise<void> {
await bindDevSeedAuth(tracer, logger); // Phase E task 19 await bindDevSeedAuth(tracer, logger); // Phase E task 19
await bindDevSeedBlog(tracer, logger); // Phase E task 18 await bindDevSeedBlog(tracer, logger); // Phase E task 18
await bindDevSeedMarketingPages(tracer, logger); // Phase E task 20 await bindDevSeedMarketingPages(tracer, logger); // Phase E task 20
await bindDevSeedNavigation(); await bindDevSeedNavigation(tracer, logger); // Phase E task 21
await bindDevSeedMedia(); await bindDevSeedMedia();
} }

View File

@@ -1,11 +1,14 @@
import "reflect-metadata"; import "reflect-metadata";
import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { NoopTracer, NoopLogger } from "@repo/core-shared/instrumentation";
import { bindDevSeedNavigation } from "@/di/bind-dev-seed"; import { bindDevSeedNavigation } from "@/di/bind-dev-seed";
import { navigationContainer } from "@/di/container"; import { navigationContainer } from "@/di/container";
import { NAVIGATION_SYMBOLS } from "@/di/symbols"; import { NAVIGATION_SYMBOLS } from "@/di/symbols";
import { MockHeaderRepository } from "@/infrastructure/repositories/header.repository.mock"; import { MockHeaderRepository } from "@/infrastructure/repositories/header.repository.mock";
import type { IHeaderRepository } from "@/application/repositories/header.repository.interface"; import type { IHeaderRepository } from "@/application/repositories/header.repository.interface";
const noop = { tracer: new NoopTracer(), logger: new NoopLogger() };
describe("bindDevSeedNavigation", () => { describe("bindDevSeedNavigation", () => {
// Each test starts from the default mock binding and tears down afterwards // Each test starts from the default mock binding and tears down afterwards
// so the global navigationContainer state stays clean for siblings. // 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 () => { it("populates the header repository with the dev header", async () => {
await bindDevSeedNavigation(); await bindDevSeedNavigation(noop.tracer, noop.logger);
const repo = navigationContainer.get<IHeaderRepository>( const repo = navigationContainer.get<IHeaderRepository>(
NAVIGATION_SYMBOLS.IHeaderRepository, NAVIGATION_SYMBOLS.IHeaderRepository,
@@ -39,7 +42,7 @@ describe("bindDevSeedNavigation", () => {
}); });
it("seeds a header with a non-empty items array", async () => { it("seeds a header with a non-empty items array", async () => {
await bindDevSeedNavigation(); await bindDevSeedNavigation(noop.tracer, noop.logger);
const repo = navigationContainer.get<IHeaderRepository>( const repo = navigationContainer.get<IHeaderRepository>(
NAVIGATION_SYMBOLS.IHeaderRepository, NAVIGATION_SYMBOLS.IHeaderRepository,
@@ -53,13 +56,13 @@ describe("bindDevSeedNavigation", () => {
}); });
it("is idempotent — calling twice rebuilds a fresh populated repo", async () => { it("is idempotent — calling twice rebuilds a fresh populated repo", async () => {
await bindDevSeedNavigation(); await bindDevSeedNavigation(noop.tracer, noop.logger);
const before = navigationContainer.get<IHeaderRepository>( const before = navigationContainer.get<IHeaderRepository>(
NAVIGATION_SYMBOLS.IHeaderRepository, NAVIGATION_SYMBOLS.IHeaderRepository,
); );
const beforeHeader = await before.getHeader(); const beforeHeader = await before.getHeader();
await bindDevSeedNavigation(); await bindDevSeedNavigation(noop.tracer, noop.logger);
const after = navigationContainer.get<IHeaderRepository>( const after = navigationContainer.get<IHeaderRepository>(
NAVIGATION_SYMBOLS.IHeaderRepository, NAVIGATION_SYMBOLS.IHeaderRepository,
); );

View File

@@ -1,7 +1,15 @@
import {
withSpan,
INSTRUMENTATION_SYMBOLS,
type ITracer,
type ILogger,
} from "@repo/core-shared/instrumentation";
import { navigationContainer } from "./container.js"; import { navigationContainer } from "./container.js";
import { NAVIGATION_SYMBOLS } from "./symbols.js"; import { NAVIGATION_SYMBOLS } from "./symbols.js";
import { MockHeaderRepository } from "../infrastructure/repositories/header.repository.mock.js"; import { MockHeaderRepository } from "../infrastructure/repositories/header.repository.mock.js";
import { buildDevHeader } from "../__seeds__/dev.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"; 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 * Idempotent: safe to call multiple times; each call rebuilds a fresh
* populated repo and rebinds the symbol. * populated repo and rebinds the symbol.
*/ */
export async function bindDevSeedNavigation(): Promise<void> { export async function bindDevSeedNavigation(tracer: ITracer, logger: ILogger): Promise<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<ITracer>(INSTRUMENTATION_SYMBOLS.TRACER).toConstantValue(tracer);
navigationContainer.bind<ILogger>(INSTRUMENTATION_SYMBOLS.LOGGER).toConstantValue(logger);
if (navigationContainer.isBound(NAVIGATION_SYMBOLS.IHeaderRepository)) { if (navigationContainer.isBound(NAVIGATION_SYMBOLS.IHeaderRepository)) {
navigationContainer.unbind(NAVIGATION_SYMBOLS.IHeaderRepository); navigationContainer.unbind(NAVIGATION_SYMBOLS.IHeaderRepository);
} }
const repo = new MockHeaderRepository(buildDevHeader(), tracer, logger);
navigationContainer navigationContainer
.bind<IHeaderRepository>(NAVIGATION_SYMBOLS.IHeaderRepository) .bind<IHeaderRepository>(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),
),
);
} }

View File

@@ -1,13 +1,65 @@
import type { SanitizedConfig } from "payload"; import type { SanitizedConfig } from "payload";
import {
withSpan,
INSTRUMENTATION_SYMBOLS,
type ITracer,
type ILogger,
} from "@repo/core-shared/instrumentation";
import { navigationContainer } from "./container"; import { navigationContainer } from "./container";
import { NAVIGATION_SYMBOLS } from "./symbols"; import { NAVIGATION_SYMBOLS } from "./symbols";
import { HeaderRepository } from "../infrastructure/repositories/header.repository"; 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<ITracer>(INSTRUMENTATION_SYMBOLS.TRACER).toConstantValue(tracer);
navigationContainer.bind<ILogger>(INSTRUMENTATION_SYMBOLS.LOGGER).toConstantValue(logger);
// Real repository
if (navigationContainer.isBound(NAVIGATION_SYMBOLS.IHeaderRepository)) { if (navigationContainer.isBound(NAVIGATION_SYMBOLS.IHeaderRepository)) {
navigationContainer.unbind(NAVIGATION_SYMBOLS.IHeaderRepository); navigationContainer.unbind(NAVIGATION_SYMBOLS.IHeaderRepository);
} }
const repo = new HeaderRepository(config, tracer, logger);
navigationContainer navigationContainer
.bind(NAVIGATION_SYMBOLS.IHeaderRepository) .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),
),
);
} }

View File

@@ -1,5 +1,11 @@
import "reflect-metadata"; import "reflect-metadata";
import { injectable } from "inversify"; 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 { IHeaderRepository } from "../../application/repositories/header.repository.interface";
import type { Header, HeaderItem } from "../../entities/models/header"; import type { Header, HeaderItem } from "../../entities/models/header";
@@ -13,12 +19,27 @@ const DEFAULT_ITEMS: HeaderItem[] = [
@injectable() @injectable()
export class MockHeaderRepository implements IHeaderRepository { export class MockHeaderRepository implements IHeaderRepository {
private readonly data: Header; 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.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<Header> { async getHeader(): Promise<Header> {
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;
},
);
} }
} }

View File

@@ -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");
});
});

View File

@@ -2,6 +2,12 @@ import "reflect-metadata";
import { injectable } from "inversify"; import { injectable } from "inversify";
import { getPayload } from "payload"; import { getPayload } from "payload";
import type { SanitizedConfig } 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 { IHeaderRepository } from "../../application/repositories/header.repository.interface";
import type { Header, HeaderItem } from "../../entities/models/header"; import type { Header, HeaderItem } from "../../entities/models/header";
@@ -15,34 +21,59 @@ type PayloadHeaderGlobal = {
}> | null; }> | null;
}; };
const FEATURE = "navigation" as const;
const REPO = "header" as const;
@injectable() @injectable()
export class HeaderRepository implements IHeaderRepository { export class HeaderRepository implements IHeaderRepository {
private config: SanitizedConfig; 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.config = config;
this.tracer = tracer;
this.logger = logger;
} }
async getHeader(): Promise<Header> { async getHeader(): Promise<Header> {
const payload = await getPayload({ config: this.config }); return this.tracer.startSpan(
const doc = (await payload.findGlobal({ { name: "header.getHeader", op: "repository", attributes: {} },
slug: "header", async (span) => {
overrideAccess: true, try {
})) as PayloadHeaderGlobal; const payload = await getPayload({ config: this.config });
const doc = (await payload.findGlobal({
slug: "header",
overrideAccess: true,
})) as PayloadHeaderGlobal;
const logoId = const logoId =
typeof doc.logo === "object" && doc.logo !== null typeof doc.logo === "object" && doc.logo !== null
? String(doc.logo.id) ? String(doc.logo.id)
: doc.logo != null : doc.logo != null
? String(doc.logo) ? String(doc.logo)
: undefined; : undefined;
const items: HeaderItem[] = (doc.items ?? []).map((item) => ({ const items: HeaderItem[] = (doc.items ?? []).map((item) => ({
label: item.label ?? "", label: item.label ?? "",
href: item.href ?? "", href: item.href ?? "",
external: item.external ?? false, 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;
}
},
);
} }
} }