diff --git a/apps/web-next/src/server/bind-production.ts b/apps/web-next/src/server/bind-production.ts index afb8ebe..d5d2b3d 100644 --- a/apps/web-next/src/server/bind-production.ts +++ b/apps/web-next/src/server/bind-production.ts @@ -58,7 +58,7 @@ export async function bindAllProduction(): Promise { bindProductionBlog(resolvedConfig, tracer, logger); // Phase E task 18 bindProductionMarketingPages(resolvedConfig, tracer, logger); // Phase E task 20 bindProductionNavigation(resolvedConfig, tracer, logger); // Phase E task 21 - bindProductionMedia(resolvedConfig); + bindProductionMedia(resolvedConfig, tracer, logger); // Phase E task 22 } /** @@ -74,7 +74,7 @@ export async function bindAllDevSeed(): Promise { await bindDevSeedBlog(tracer, logger); // Phase E task 18 await bindDevSeedMarketingPages(tracer, logger); // Phase E task 20 await bindDevSeedNavigation(tracer, logger); // Phase E task 21 - await bindDevSeedMedia(); + await bindDevSeedMedia(tracer, logger); // Phase E task 22 } /** diff --git a/packages/media/src/di/bind-dev-seed.test.ts b/packages/media/src/di/bind-dev-seed.test.ts index b5c07b7..d56c856 100644 --- a/packages/media/src/di/bind-dev-seed.test.ts +++ b/packages/media/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 { bindDevSeedMedia } from "@/di/bind-dev-seed"; import { mediaContainer } from "@/di/container"; import { MEDIA_SYMBOLS } from "@/di/symbols"; import { MockMediaRepository } from "@/infrastructure/repositories/media.repository.mock"; import type { IMediaRepository } from "@/application/repositories/media.repository.interface"; +const noop = { tracer: new NoopTracer(), logger: new NoopLogger() }; + describe("bindDevSeedMedia", () => { // Each test starts from the default empty-mock binding and tears down // afterwards so the global mediaContainer state stays clean for siblings. @@ -28,7 +31,7 @@ describe("bindDevSeedMedia", () => { }); it("populates the repository with the dev media entries", async () => { - await bindDevSeedMedia(); + await bindDevSeedMedia(noop.tracer, noop.logger); const repo = mediaContainer.get( MEDIA_SYMBOLS.IMediaRepository, @@ -39,7 +42,7 @@ describe("bindDevSeedMedia", () => { }); it("seeds placeholder-1 reachable by id", async () => { - await bindDevSeedMedia(); + await bindDevSeedMedia(noop.tracer, noop.logger); const repo = mediaContainer.get( MEDIA_SYMBOLS.IMediaRepository, @@ -52,13 +55,13 @@ describe("bindDevSeedMedia", () => { }); it("is idempotent — calling twice rebuilds a fresh populated repo", async () => { - await bindDevSeedMedia(); + await bindDevSeedMedia(noop.tracer, noop.logger); const before = mediaContainer.get( MEDIA_SYMBOLS.IMediaRepository, ); const beforeCount = (await before.listMedia()).length; - await bindDevSeedMedia(); + await bindDevSeedMedia(noop.tracer, noop.logger); const after = mediaContainer.get( MEDIA_SYMBOLS.IMediaRepository, ); diff --git a/packages/media/src/di/bind-dev-seed.ts b/packages/media/src/di/bind-dev-seed.ts index 9b400e4..77610fb 100644 --- a/packages/media/src/di/bind-dev-seed.ts +++ b/packages/media/src/di/bind-dev-seed.ts @@ -1,7 +1,19 @@ +import { + withSpan, + INSTRUMENTATION_SYMBOLS, + type ITracer, + type ILogger, +} from "@repo/core-shared/instrumentation"; import { mediaContainer } from "./container.js"; import { MEDIA_SYMBOLS } from "./symbols.js"; import { MockMediaRepository } from "../infrastructure/repositories/media.repository.mock.js"; import { buildDevMedia } from "../__seeds__/dev.js"; +import { getMediaUseCase } from "../application/use-cases/get-media.use-case.js"; +import { listMediaUseCase } from "../application/use-cases/list-media.use-case.js"; +import { deleteMediaUseCase } from "../application/use-cases/delete-media.use-case.js"; +import { getMediaController } from "../interface-adapters/controllers/get-media.controller.js"; +import { listMediaController } from "../interface-adapters/controllers/list-media.controller.js"; +import { deleteMediaController } from "../interface-adapters/controllers/delete-media.controller.js"; import type { IMediaRepository } from "../application/repositories/media.repository.interface.js"; /** @@ -14,12 +26,22 @@ import type { IMediaRepository } from "../application/repositories/media.reposit * Idempotent: safe to call multiple times; each call rebuilds a fresh * populated repo and rebinds the symbol. */ -export async function bindDevSeedMedia(): Promise { +export async function bindDevSeedMedia(tracer: ITracer, logger: ILogger): Promise { + // Bind shared instrumentation into feature container + if (mediaContainer.isBound(INSTRUMENTATION_SYMBOLS.TRACER)) { + mediaContainer.unbind(INSTRUMENTATION_SYMBOLS.TRACER); + } + if (mediaContainer.isBound(INSTRUMENTATION_SYMBOLS.LOGGER)) { + mediaContainer.unbind(INSTRUMENTATION_SYMBOLS.LOGGER); + } + mediaContainer.bind(INSTRUMENTATION_SYMBOLS.TRACER).toConstantValue(tracer); + mediaContainer.bind(INSTRUMENTATION_SYMBOLS.LOGGER).toConstantValue(logger); + if (mediaContainer.isBound(MEDIA_SYMBOLS.IMediaRepository)) { mediaContainer.unbind(MEDIA_SYMBOLS.IMediaRepository); } - const repo = new MockMediaRepository(); + const repo = new MockMediaRepository(tracer, logger); for (const media of buildDevMedia()) { await repo._store(media); } @@ -27,4 +49,63 @@ export async function bindDevSeedMedia(): Promise { mediaContainer .bind(MEDIA_SYMBOLS.IMediaRepository) .toConstantValue(repo); + + // Wrap use cases + controllers identically to bind-production + const wrappedGetMedia = withSpan( + tracer, + { name: "media.getMedia", op: "use-case" }, + getMediaUseCase(repo), + ); + const wrappedListMedia = withSpan( + tracer, + { name: "media.listMedia", op: "use-case" }, + listMediaUseCase(repo), + ); + const wrappedDeleteMedia = withSpan( + tracer, + { name: "media.deleteMedia", op: "use-case" }, + deleteMediaUseCase(repo), + ); + + for (const sym of [ + MEDIA_SYMBOLS.IGetMediaUseCase, + MEDIA_SYMBOLS.IListMediaUseCase, + MEDIA_SYMBOLS.IDeleteMediaUseCase, + MEDIA_SYMBOLS.IGetMediaController, + MEDIA_SYMBOLS.IListMediaController, + MEDIA_SYMBOLS.IDeleteMediaController, + ]) { + if (mediaContainer.isBound(sym)) mediaContainer.unbind(sym); + } + mediaContainer.bind(MEDIA_SYMBOLS.IGetMediaUseCase).toConstantValue(wrappedGetMedia); + mediaContainer.bind(MEDIA_SYMBOLS.IListMediaUseCase).toConstantValue(wrappedListMedia); + mediaContainer.bind(MEDIA_SYMBOLS.IDeleteMediaUseCase).toConstantValue(wrappedDeleteMedia); + + mediaContainer + .bind(MEDIA_SYMBOLS.IGetMediaController) + .toConstantValue( + withSpan( + tracer, + { name: "media.getMedia", op: "controller" }, + getMediaController(wrappedGetMedia), + ), + ); + mediaContainer + .bind(MEDIA_SYMBOLS.IListMediaController) + .toConstantValue( + withSpan( + tracer, + { name: "media.listMedia", op: "controller" }, + listMediaController(wrappedListMedia), + ), + ); + mediaContainer + .bind(MEDIA_SYMBOLS.IDeleteMediaController) + .toConstantValue( + withSpan( + tracer, + { name: "media.deleteMedia", op: "controller" }, + deleteMediaController(wrappedDeleteMedia), + ), + ); } diff --git a/packages/media/src/di/bind-production.ts b/packages/media/src/di/bind-production.ts index 6bc350c..1178bcc 100644 --- a/packages/media/src/di/bind-production.ts +++ b/packages/media/src/di/bind-production.ts @@ -1,13 +1,105 @@ import type { SanitizedConfig } from "payload"; +import { + withSpan, + INSTRUMENTATION_SYMBOLS, + type ITracer, + type ILogger, +} from "@repo/core-shared/instrumentation"; import { mediaContainer } from "./container"; import { MEDIA_SYMBOLS } from "./symbols"; import { MediaRepository } from "../infrastructure/repositories/media.repository"; +import { getMediaUseCase } from "../application/use-cases/get-media.use-case"; +import { listMediaUseCase } from "../application/use-cases/list-media.use-case"; +import { deleteMediaUseCase } from "../application/use-cases/delete-media.use-case"; +import { getMediaController } from "../interface-adapters/controllers/get-media.controller"; +import { listMediaController } from "../interface-adapters/controllers/list-media.controller"; +import { deleteMediaController } from "../interface-adapters/controllers/delete-media.controller"; -export function bindProductionMedia(config: SanitizedConfig): void { +export function bindProductionMedia( + config: SanitizedConfig, + tracer: ITracer, + logger: ILogger, +): void { + // Bind shared instrumentation into feature container + if (mediaContainer.isBound(INSTRUMENTATION_SYMBOLS.TRACER)) { + mediaContainer.unbind(INSTRUMENTATION_SYMBOLS.TRACER); + } + if (mediaContainer.isBound(INSTRUMENTATION_SYMBOLS.LOGGER)) { + mediaContainer.unbind(INSTRUMENTATION_SYMBOLS.LOGGER); + } + mediaContainer.bind(INSTRUMENTATION_SYMBOLS.TRACER).toConstantValue(tracer); + mediaContainer.bind(INSTRUMENTATION_SYMBOLS.LOGGER).toConstantValue(logger); + + // Real repository if (mediaContainer.isBound(MEDIA_SYMBOLS.IMediaRepository)) { mediaContainer.unbind(MEDIA_SYMBOLS.IMediaRepository); } + const repo = new MediaRepository(config, tracer, logger); mediaContainer .bind(MEDIA_SYMBOLS.IMediaRepository) - .toConstantValue(new MediaRepository(config)); + .toConstantValue(repo); + + // Use cases — wrapped with span at bind time + const wrappedGetMedia = withSpan( + tracer, + { name: "media.getMedia", op: "use-case" }, + getMediaUseCase(repo), + ); + const wrappedListMedia = withSpan( + tracer, + { name: "media.listMedia", op: "use-case" }, + listMediaUseCase(repo), + ); + const wrappedDeleteMedia = withSpan( + tracer, + { name: "media.deleteMedia", op: "use-case" }, + deleteMediaUseCase(repo), + ); + + for (const sym of [ + MEDIA_SYMBOLS.IGetMediaUseCase, + MEDIA_SYMBOLS.IListMediaUseCase, + MEDIA_SYMBOLS.IDeleteMediaUseCase, + ]) { + if (mediaContainer.isBound(sym)) mediaContainer.unbind(sym); + } + mediaContainer.bind(MEDIA_SYMBOLS.IGetMediaUseCase).toConstantValue(wrappedGetMedia); + mediaContainer.bind(MEDIA_SYMBOLS.IListMediaUseCase).toConstantValue(wrappedListMedia); + mediaContainer.bind(MEDIA_SYMBOLS.IDeleteMediaUseCase).toConstantValue(wrappedDeleteMedia); + + // Controllers — wrapped with span at bind time + for (const sym of [ + MEDIA_SYMBOLS.IGetMediaController, + MEDIA_SYMBOLS.IListMediaController, + MEDIA_SYMBOLS.IDeleteMediaController, + ]) { + if (mediaContainer.isBound(sym)) mediaContainer.unbind(sym); + } + mediaContainer + .bind(MEDIA_SYMBOLS.IGetMediaController) + .toConstantValue( + withSpan( + tracer, + { name: "media.getMedia", op: "controller" }, + getMediaController(wrappedGetMedia), + ), + ); + mediaContainer + .bind(MEDIA_SYMBOLS.IListMediaController) + .toConstantValue( + withSpan( + tracer, + { name: "media.listMedia", op: "controller" }, + listMediaController(wrappedListMedia), + ), + ); + mediaContainer + .bind(MEDIA_SYMBOLS.IDeleteMediaController) + .toConstantValue( + withSpan( + tracer, + { name: "media.deleteMedia", op: "controller" }, + deleteMediaController(wrappedDeleteMedia), + ), + ); } diff --git a/packages/media/src/infrastructure/repositories/media.repository.mock.ts b/packages/media/src/infrastructure/repositories/media.repository.mock.ts index f3d7b12..4385768 100644 --- a/packages/media/src/infrastructure/repositories/media.repository.mock.ts +++ b/packages/media/src/infrastructure/repositories/media.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 { IMediaRepository } from "../../application/repositories/media.repository.interface"; import type { Media } from "../../entities/models/media"; @@ -7,6 +13,17 @@ import type { Media } from "../../entities/models/media"; @injectable() export class MockMediaRepository implements IMediaRepository { private _media: Media[] = []; + private tracer: ITracer; + private logger: ILogger; + + constructor( + tracer: ITracer = new NoopTracer(), + logger: ILogger = new NoopLogger(), + ) { + this.tracer = tracer; + this.logger = logger; + void this.logger; // currently unused; reserved for future mock-thrown captures + } /** Test helper — seeds the in-memory store directly. */ async _store(media: Media): Promise { @@ -14,16 +31,44 @@ export class MockMediaRepository implements IMediaRepository { } async getMedia(id: string): Promise { - return this._media.find((m) => m.id === id); + return this.tracer.startSpan( + { name: "media.getMedia", op: "repository", attributes: { id } }, + async (span) => { + const found = this._media.find((m) => m.id === id); + span.setAttribute("found", Boolean(found)); + return found; + }, + ); } async listMedia(opts?: { limit?: number; offset?: number }): Promise { - const offset = opts?.offset ?? 0; - const limit = opts?.limit ?? 50; - return this._media.slice(offset, offset + limit); + return this.tracer.startSpan( + { + name: "media.listMedia", + op: "repository", + attributes: { + limit: opts?.limit ?? null, + offset: opts?.offset ?? null, + }, + }, + async (span) => { + const offset = opts?.offset ?? 0; + const limit = opts?.limit ?? 50; + const result = this._media.slice(offset, offset + limit); + span.setAttribute("count", result.length); + return result; + }, + ); } async deleteMedia(id: string): Promise { - this._media = this._media.filter((m) => m.id !== id); + return this.tracer.startSpan( + { name: "media.deleteMedia", op: "repository", attributes: { id } }, + async (span) => { + const before = this._media.length; + this._media = this._media.filter((m) => m.id !== id); + span.setAttribute("deleted", this._media.length < before); + }, + ); } } diff --git a/packages/media/src/infrastructure/repositories/media.repository.span.test.ts b/packages/media/src/infrastructure/repositories/media.repository.span.test.ts new file mode 100644 index 0000000..120eecd --- /dev/null +++ b/packages/media/src/infrastructure/repositories/media.repository.span.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect } from "vitest"; +import { RecordingTracer, RecordingLogger } from "@repo/core-testing/instrumentation"; +import { MockMediaRepository } from "@/infrastructure/repositories/media.repository.mock"; +import type { Media } from "@/entities/models/media"; + +const SAMPLE_MEDIA: Media = { + id: "m1", + alt: "Test image", + url: "/test/image.jpg", + filename: "image.jpg", + mimeType: "image/jpeg", + filesize: 1024, +}; + +// Mock repo also wraps in spans (R42). +describe("MockMediaRepository emits spans (R42)", () => { + it("getMedia emits one span with op='repository' and found attribute", async () => { + const tracer = new RecordingTracer(); + const logger = new RecordingLogger(); + const repo = new MockMediaRepository(tracer, logger); + await repo._store(SAMPLE_MEDIA); + await repo.getMedia("m1"); + expect(tracer.spans).toHaveLength(1); + const getSpan = tracer.findSpan("media.getMedia"); + expect(getSpan).toBeDefined(); + expect(getSpan).toMatchObject({ name: "media.getMedia", op: "repository" }); + expect(getSpan!.attributes.found).toBe(true); + }); + + it("listMedia emits a span with count attribute", async () => { + const tracer = new RecordingTracer(); + const repo = new MockMediaRepository(tracer); + await repo._store(SAMPLE_MEDIA); + tracer.reset(); + await repo.listMedia({ limit: 10 }); + expect(tracer.findSpan("media.listMedia")).toBeDefined(); + expect(tracer.findSpan("media.listMedia")!.attributes.limit).toBe(10); + expect(tracer.findSpan("media.listMedia")!.attributes.count).toBe(1); + }); + + it("deleteMedia emits a span with deleted=true", async () => { + const tracer = new RecordingTracer(); + const repo = new MockMediaRepository(tracer); + await repo._store(SAMPLE_MEDIA); + tracer.reset(); + await repo.deleteMedia("m1"); + expect(tracer.findSpan("media.deleteMedia")).toBeDefined(); + expect(tracer.findSpan("media.deleteMedia")!.attributes.deleted).toBe(true); + expect(tracer.findSpan("media.deleteMedia")!.attributes.id).toBe("m1"); + }); +}); diff --git a/packages/media/src/infrastructure/repositories/media.repository.test.ts b/packages/media/src/infrastructure/repositories/media.repository.test.ts index c7fc184..b8d59e8 100644 --- a/packages/media/src/infrastructure/repositories/media.repository.test.ts +++ b/packages/media/src/infrastructure/repositories/media.repository.test.ts @@ -32,7 +32,10 @@ function buildPayloadStub() { findByID: vi.fn( async ({ id }: { collection: string; id: string; overrideAccess?: boolean }) => { const doc = store.get(String(id)); - if (!doc) throw new Error(`Not found: ${id}`); + if (!doc) { + const err = Object.assign(new Error(`Not found: ${id}`), { status: 404 }); + throw err; + } return doc; }, ), @@ -71,7 +74,9 @@ describe("MediaRepository", () => { it("returns undefined when Payload throws (not found)", async () => { const { getPayload } = await import("payload"); (getPayload as ReturnType).mockResolvedValue({ - findByID: vi.fn().mockRejectedValue(new Error("Not found")), + findByID: vi.fn().mockRejectedValue( + Object.assign(new Error("Not found"), { status: 404 }), + ), }); const repo = new MediaRepository(stubPayloadConfig); diff --git a/packages/media/src/infrastructure/repositories/media.repository.ts b/packages/media/src/infrastructure/repositories/media.repository.ts index 164ec12..9ba8e09 100644 --- a/packages/media/src/infrastructure/repositories/media.repository.ts +++ b/packages/media/src/infrastructure/repositories/media.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 { IMediaRepository } from "../../application/repositories/media.repository.interface"; import type { Media } from "../../entities/models/media"; @@ -30,47 +36,114 @@ function mapDoc(doc: PayloadMediaDoc): Media { }; } +const FEATURE = "media" as const; +const REPO = "media" as const; + @injectable() export class MediaRepository implements IMediaRepository { 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 getMedia(id: string): Promise { - const payload = await getPayload({ config: this.config }); - try { - const doc = await payload.findByID({ - collection: "media", - id, - overrideAccess: true, - }); - return mapDoc(doc as PayloadMediaDoc); - } catch { - return undefined; - } + return this.tracer.startSpan( + { name: "media.getMedia", op: "repository", attributes: { id } }, + async (span) => { + try { + const payload = await getPayload({ config: this.config }); + const doc = await payload.findByID({ + collection: "media", + id, + overrideAccess: true, + }); + span.setAttribute("found", true); + return mapDoc(doc as PayloadMediaDoc); + } 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: "getMedia" }, + }); + span.setStatus("error", err instanceof Error ? err.message : String(err)); + throw err; + } + }, + ); } async listMedia(opts?: { limit?: number; offset?: number }): Promise { - const payload = await getPayload({ config: this.config }); - const result = await payload.find({ - collection: "media", - limit: opts?.limit ?? 50, - page: opts?.offset - ? Math.floor(opts.offset / (opts.limit ?? 50)) + 1 - : 1, - overrideAccess: true, - }); - return result.docs.map((d) => mapDoc(d as PayloadMediaDoc)); + return this.tracer.startSpan( + { + name: "media.listMedia", + op: "repository", + attributes: { + limit: opts?.limit ?? null, + offset: opts?.offset ?? null, + }, + }, + async (span) => { + try { + const payload = await getPayload({ config: this.config }); + const result = await payload.find({ + collection: "media", + limit: opts?.limit ?? 50, + page: opts?.offset + ? Math.floor(opts.offset / (opts.limit ?? 50)) + 1 + : 1, + overrideAccess: true, + }); + const items = result.docs.map((d) => mapDoc(d as PayloadMediaDoc)); + span.setAttribute("count", items.length); + return items; + } catch (err) { + this.logger.captureException(err, { + tags: { feature: FEATURE, repo: REPO, method: "listMedia" }, + }); + span.setStatus("error", err instanceof Error ? err.message : String(err)); + throw err; + } + }, + ); } async deleteMedia(id: string): Promise { - const payload = await getPayload({ config: this.config }); - await payload.delete({ - collection: "media", - id, - overrideAccess: true, - }); + return this.tracer.startSpan( + { name: "media.deleteMedia", op: "repository", attributes: { id } }, + async (span) => { + try { + const payload = await getPayload({ config: this.config }); + await payload.delete({ + collection: "media", + id, + overrideAccess: true, + }); + span.setAttribute("deleted", true); + } catch (err) { + span.setAttribute("deleted", false); + this.logger.captureException(err, { + tags: { feature: FEATURE, repo: REPO, method: "deleteMedia" }, + }); + span.setStatus("error", err instanceof Error ? err.message : String(err)); + throw err; + } + }, + ); } }