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
|
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.tracer.startSpan(
|
||||||
|
{ name: "header.getHeader", op: "repository", attributes: {} },
|
||||||
|
async (span) => {
|
||||||
|
span.setAttribute("itemCount", this.data.items.length);
|
||||||
return this.data;
|
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 { 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,15 +21,30 @@ 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> {
|
||||||
|
return this.tracer.startSpan(
|
||||||
|
{ name: "header.getHeader", op: "repository", attributes: {} },
|
||||||
|
async (span) => {
|
||||||
|
try {
|
||||||
const payload = await getPayload({ config: this.config });
|
const payload = await getPayload({ config: this.config });
|
||||||
const doc = (await payload.findGlobal({
|
const doc = (await payload.findGlobal({
|
||||||
slug: "header",
|
slug: "header",
|
||||||
@@ -43,6 +64,16 @@ export class HeaderRepository implements IHeaderRepository {
|
|||||||
external: item.external ?? false,
|
external: item.external ?? false,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
span.setAttribute("itemCount", items.length);
|
||||||
return { logoId, items };
|
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