feat(navigation): wire instrumentation — header repo spans + getHeader withSpan
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -57,7 +57,7 @@ export async function bindAllProduction(): Promise<void> {
|
||||
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<void> {
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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<IHeaderRepository>(
|
||||
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<IHeaderRepository>(
|
||||
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<IHeaderRepository>(
|
||||
NAVIGATION_SYMBOLS.IHeaderRepository,
|
||||
);
|
||||
const beforeHeader = await before.getHeader();
|
||||
|
||||
await bindDevSeedNavigation();
|
||||
await bindDevSeedNavigation(noop.tracer, noop.logger);
|
||||
const after = navigationContainer.get<IHeaderRepository>(
|
||||
NAVIGATION_SYMBOLS.IHeaderRepository,
|
||||
);
|
||||
|
||||
@@ -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<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)) {
|
||||
navigationContainer.unbind(NAVIGATION_SYMBOLS.IHeaderRepository);
|
||||
}
|
||||
|
||||
const repo = new MockHeaderRepository(buildDevHeader(), tracer, logger);
|
||||
navigationContainer
|
||||
.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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<ITracer>(INSTRUMENTATION_SYMBOLS.TRACER).toConstantValue(tracer);
|
||||
navigationContainer.bind<ILogger>(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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<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;
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<Header> {
|
||||
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;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user