From 5903cef70a409dc74db169ec1a23474c9284aa5b Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Thu, 7 May 2026 00:18:13 +0200 Subject: [PATCH] =?UTF-8?q?feat(blog):=20wire=20instrumentation=20?= =?UTF-8?q?=E2=80=94=20repo=20spans=20+=20use-case/controller=20withSpan?= =?UTF-8?q?=20+=20logger=20capture?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/web-next/src/server/bind-production.ts | 12 +- packages/blog/src/di/bind-dev-seed.test.ts | 20 +- packages/blog/src/di/bind-dev-seed.ts | 89 ++++++- packages/blog/src/di/bind-production.ts | 102 +++++++- .../repositories/articles.repository.mock.ts | 102 ++++++-- .../articles.repository.span.test.ts | 43 ++++ .../repositories/articles.repository.test.ts | 10 +- .../repositories/articles.repository.ts | 239 +++++++++++++----- 8 files changed, 506 insertions(+), 111 deletions(-) create mode 100644 packages/blog/src/infrastructure/repositories/articles.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 6c65b29..5195e42 100644 --- a/apps/web-next/src/server/bind-production.ts +++ b/apps/web-next/src/server/bind-production.ts @@ -47,17 +47,15 @@ function resolveInstrumentation(): { tracer: ITracer; logger: ILogger } { /** * Production path: swap each feature's mock repository binding for the real * Payload-backed one. Constructs `new XRepository(config, tracer, logger)` per - * feature once Phase E feature wiring lands. Until Phase E, this still calls - * the existing `bindProductionX(config)` signature; the unused `tracer`/`logger` - * are forwarded by Phase E commits as those binders are updated. + * feature as Phase E feature wiring lands (blog: task 18; remaining: tasks 19–22). */ export async function bindAllProduction(): Promise { if (bound) return; bound = true; - resolveInstrumentation(); // Rule 0 + const { tracer, logger } = resolveInstrumentation(); // Rule 0 const resolvedConfig = await config; bindProductionAuth(resolvedConfig); - bindProductionBlog(resolvedConfig); + bindProductionBlog(resolvedConfig, tracer, logger); // Phase E task 18 bindProductionMarketingPages(resolvedConfig); bindProductionNavigation(resolvedConfig); bindProductionMedia(resolvedConfig); @@ -71,9 +69,9 @@ export async function bindAllProduction(): Promise { export async function bindAllDevSeed(): Promise { if (bound) return; bound = true; - resolveInstrumentation(); // Rule 0 + const { tracer, logger } = resolveInstrumentation(); // Rule 0 await bindDevSeedAuth(); - await bindDevSeedBlog(); + await bindDevSeedBlog(tracer, logger); // Phase E task 18 await bindDevSeedMarketingPages(); await bindDevSeedNavigation(); await bindDevSeedMedia(); diff --git a/packages/blog/src/di/bind-dev-seed.test.ts b/packages/blog/src/di/bind-dev-seed.test.ts index fef92da..ec91fe6 100644 --- a/packages/blog/src/di/bind-dev-seed.test.ts +++ b/packages/blog/src/di/bind-dev-seed.test.ts @@ -1,5 +1,6 @@ import "reflect-metadata"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { NoopTracer, NoopLogger } from "@repo/core-shared/instrumentation"; import { bindDevSeedBlog } from "@/di/bind-dev-seed"; import { blogContainer } from "@/di/container"; import { BLOG_SYMBOLS } from "@/di/symbols"; @@ -7,11 +8,14 @@ import { MockArticlesRepository } from "@/infrastructure/repositories/articles.r import type { IArticlesRepository } from "@/application/repositories/articles.repository.interface"; describe("bindDevSeedBlog", () => { + const tracer = new NoopTracer(); + const logger = new NoopLogger(); + // Each test starts from the default empty-mock binding and tears down // afterwards so the global blogContainer state stays clean for siblings. beforeEach(() => { - if (blogContainer.isBound(BLOG_SYMBOLS.IArticlesRepository)) { - blogContainer.unbind(BLOG_SYMBOLS.IArticlesRepository); + for (const sym of Object.values(BLOG_SYMBOLS)) { + if (blogContainer.isBound(sym)) blogContainer.unbind(sym); } blogContainer .bind(BLOG_SYMBOLS.IArticlesRepository) @@ -19,8 +23,8 @@ describe("bindDevSeedBlog", () => { }); afterEach(() => { - if (blogContainer.isBound(BLOG_SYMBOLS.IArticlesRepository)) { - blogContainer.unbind(BLOG_SYMBOLS.IArticlesRepository); + for (const sym of Object.values(BLOG_SYMBOLS)) { + if (blogContainer.isBound(sym)) blogContainer.unbind(sym); } blogContainer .bind(BLOG_SYMBOLS.IArticlesRepository) @@ -28,7 +32,7 @@ describe("bindDevSeedBlog", () => { }); it("populates the repository with the dev articles", async () => { - await bindDevSeedBlog(); + await bindDevSeedBlog(tracer, logger); const repo = blogContainer.get( BLOG_SYMBOLS.IArticlesRepository, @@ -39,7 +43,7 @@ describe("bindDevSeedBlog", () => { }); it("seeds the welcome article reachable by slug", async () => { - await bindDevSeedBlog(); + await bindDevSeedBlog(tracer, logger); const repo = blogContainer.get( BLOG_SYMBOLS.IArticlesRepository, @@ -52,13 +56,13 @@ describe("bindDevSeedBlog", () => { }); it("is idempotent — calling twice rebuilds a fresh populated repo", async () => { - await bindDevSeedBlog(); + await bindDevSeedBlog(tracer, logger); const before = blogContainer.get( BLOG_SYMBOLS.IArticlesRepository, ); const beforeCount = (await before.getArticles()).length; - await bindDevSeedBlog(); + await bindDevSeedBlog(tracer, logger); const after = blogContainer.get( BLOG_SYMBOLS.IArticlesRepository, ); diff --git a/packages/blog/src/di/bind-dev-seed.ts b/packages/blog/src/di/bind-dev-seed.ts index 9fa177e..7fa5696 100644 --- a/packages/blog/src/di/bind-dev-seed.ts +++ b/packages/blog/src/di/bind-dev-seed.ts @@ -1,7 +1,19 @@ +import { + withSpan, + INSTRUMENTATION_SYMBOLS, + type ITracer, + type ILogger, +} from "@repo/core-shared/instrumentation"; import { blogContainer } from "./container.js"; import { BLOG_SYMBOLS } from "./symbols.js"; import { MockArticlesRepository } from "../infrastructure/repositories/articles.repository.mock.js"; import { buildDevArticles } from "../__seeds__/dev.js"; +import { getArticlesUseCase } from "../application/use-cases/get-articles.use-case.js"; +import { getArticleBySlugUseCase } from "../application/use-cases/get-article-by-slug.use-case.js"; +import { createArticleUseCase } from "../application/use-cases/create-article.use-case.js"; +import { getArticlesController } from "../interface-adapters/controllers/get-articles.controller.js"; +import { getArticleBySlugController } from "../interface-adapters/controllers/get-article-by-slug.controller.js"; +import { createArticleController } from "../interface-adapters/controllers/create-article.controller.js"; import type { IArticlesRepository } from "../application/repositories/articles.repository.interface.js"; /** @@ -14,17 +26,86 @@ import type { IArticlesRepository } from "../application/repositories/articles.r * Idempotent: safe to call multiple times; each call rebuilds a fresh * populated repo and rebinds the symbol. */ -export async function bindDevSeedBlog(): Promise { +export async function bindDevSeedBlog(tracer: ITracer, logger: ILogger): Promise { + // Bind shared instrumentation into feature container + if (blogContainer.isBound(INSTRUMENTATION_SYMBOLS.TRACER)) { + blogContainer.unbind(INSTRUMENTATION_SYMBOLS.TRACER); + } + if (blogContainer.isBound(INSTRUMENTATION_SYMBOLS.LOGGER)) { + blogContainer.unbind(INSTRUMENTATION_SYMBOLS.LOGGER); + } + blogContainer.bind(INSTRUMENTATION_SYMBOLS.TRACER).toConstantValue(tracer); + blogContainer.bind(INSTRUMENTATION_SYMBOLS.LOGGER).toConstantValue(logger); + if (blogContainer.isBound(BLOG_SYMBOLS.IArticlesRepository)) { blogContainer.unbind(BLOG_SYMBOLS.IArticlesRepository); } - - const repo = new MockArticlesRepository(); + const repo = new MockArticlesRepository(tracer, logger); for (const article of buildDevArticles()) { await repo.createArticle(article); } - blogContainer .bind(BLOG_SYMBOLS.IArticlesRepository) .toConstantValue(repo); + + // Wrap use cases + controllers identically to bind-production + const wrappedGetArticles = withSpan( + tracer, + { name: "blog.getArticles", op: "use-case" }, + getArticlesUseCase(repo), + ); + const wrappedGetArticleBySlug = withSpan( + tracer, + { name: "blog.getArticleBySlug", op: "use-case" }, + getArticleBySlugUseCase(repo), + ); + const wrappedCreateArticle = withSpan( + tracer, + { name: "blog.createArticle", op: "use-case" }, + createArticleUseCase(repo), + ); + + for (const sym of [ + BLOG_SYMBOLS.IGetArticlesUseCase, + BLOG_SYMBOLS.IGetArticleBySlugUseCase, + BLOG_SYMBOLS.ICreateArticleUseCase, + BLOG_SYMBOLS.IGetArticlesController, + BLOG_SYMBOLS.IGetArticleBySlugController, + BLOG_SYMBOLS.ICreateArticleController, + ]) { + if (blogContainer.isBound(sym)) blogContainer.unbind(sym); + } + blogContainer.bind(BLOG_SYMBOLS.IGetArticlesUseCase).toConstantValue(wrappedGetArticles); + blogContainer + .bind(BLOG_SYMBOLS.IGetArticleBySlugUseCase) + .toConstantValue(wrappedGetArticleBySlug); + blogContainer.bind(BLOG_SYMBOLS.ICreateArticleUseCase).toConstantValue(wrappedCreateArticle); + + blogContainer + .bind(BLOG_SYMBOLS.IGetArticlesController) + .toConstantValue( + withSpan( + tracer, + { name: "blog.getArticles", op: "controller" }, + getArticlesController(wrappedGetArticles), + ), + ); + blogContainer + .bind(BLOG_SYMBOLS.IGetArticleBySlugController) + .toConstantValue( + withSpan( + tracer, + { name: "blog.getArticleBySlug", op: "controller" }, + getArticleBySlugController(wrappedGetArticleBySlug), + ), + ); + blogContainer + .bind(BLOG_SYMBOLS.ICreateArticleController) + .toConstantValue( + withSpan( + tracer, + { name: "blog.createArticle", op: "controller" }, + createArticleController(wrappedCreateArticle), + ), + ); } diff --git a/packages/blog/src/di/bind-production.ts b/packages/blog/src/di/bind-production.ts index 612d4c1..ba35943 100644 --- a/packages/blog/src/di/bind-production.ts +++ b/packages/blog/src/di/bind-production.ts @@ -1,13 +1,109 @@ import type { SanitizedConfig } from "payload"; +import { + withSpan, + INSTRUMENTATION_SYMBOLS, + type ITracer, + type ILogger, +} from "@repo/core-shared/instrumentation"; import { blogContainer } from "./container"; import { BLOG_SYMBOLS } from "./symbols"; import { ArticlesRepository } from "../infrastructure/repositories/articles.repository"; +import { getArticlesUseCase } from "../application/use-cases/get-articles.use-case"; +import { getArticleBySlugUseCase } from "../application/use-cases/get-article-by-slug.use-case"; +import { createArticleUseCase } from "../application/use-cases/create-article.use-case"; +import { getArticlesController } from "../interface-adapters/controllers/get-articles.controller"; +import { getArticleBySlugController } from "../interface-adapters/controllers/get-article-by-slug.controller"; +import { createArticleController } from "../interface-adapters/controllers/create-article.controller"; -export function bindProductionBlog(config: SanitizedConfig): void { +export function bindProductionBlog( + config: SanitizedConfig, + tracer: ITracer, + logger: ILogger, +): void { + // Bind shared instrumentation into feature container + if (blogContainer.isBound(INSTRUMENTATION_SYMBOLS.TRACER)) { + blogContainer.unbind(INSTRUMENTATION_SYMBOLS.TRACER); + } + if (blogContainer.isBound(INSTRUMENTATION_SYMBOLS.LOGGER)) { + blogContainer.unbind(INSTRUMENTATION_SYMBOLS.LOGGER); + } + blogContainer.bind(INSTRUMENTATION_SYMBOLS.TRACER).toConstantValue(tracer); + blogContainer.bind(INSTRUMENTATION_SYMBOLS.LOGGER).toConstantValue(logger); + + // Real repository if (blogContainer.isBound(BLOG_SYMBOLS.IArticlesRepository)) { blogContainer.unbind(BLOG_SYMBOLS.IArticlesRepository); } + const repo = new ArticlesRepository(config, tracer, logger); + blogContainer.bind(BLOG_SYMBOLS.IArticlesRepository).toConstantValue(repo); + + // Use cases — wrapped with span at bind time (R41) + const wrappedGetArticles = withSpan( + tracer, + { name: "blog.getArticles", op: "use-case" }, + getArticlesUseCase(repo), + ); + const wrappedGetArticleBySlug = withSpan( + tracer, + { name: "blog.getArticleBySlug", op: "use-case" }, + getArticleBySlugUseCase(repo), + ); + const wrappedCreateArticle = withSpan( + tracer, + { name: "blog.createArticle", op: "use-case" }, + createArticleUseCase(repo), + ); + + if (blogContainer.isBound(BLOG_SYMBOLS.IGetArticlesUseCase)) { + blogContainer.unbind(BLOG_SYMBOLS.IGetArticlesUseCase); + } + if (blogContainer.isBound(BLOG_SYMBOLS.IGetArticleBySlugUseCase)) { + blogContainer.unbind(BLOG_SYMBOLS.IGetArticleBySlugUseCase); + } + if (blogContainer.isBound(BLOG_SYMBOLS.ICreateArticleUseCase)) { + blogContainer.unbind(BLOG_SYMBOLS.ICreateArticleUseCase); + } + blogContainer.bind(BLOG_SYMBOLS.IGetArticlesUseCase).toConstantValue(wrappedGetArticles); blogContainer - .bind(BLOG_SYMBOLS.IArticlesRepository) - .toConstantValue(new ArticlesRepository(config)); + .bind(BLOG_SYMBOLS.IGetArticleBySlugUseCase) + .toConstantValue(wrappedGetArticleBySlug); + blogContainer.bind(BLOG_SYMBOLS.ICreateArticleUseCase).toConstantValue(wrappedCreateArticle); + + // Controllers — wrapped with span at bind time + if (blogContainer.isBound(BLOG_SYMBOLS.IGetArticlesController)) { + blogContainer.unbind(BLOG_SYMBOLS.IGetArticlesController); + } + if (blogContainer.isBound(BLOG_SYMBOLS.IGetArticleBySlugController)) { + blogContainer.unbind(BLOG_SYMBOLS.IGetArticleBySlugController); + } + if (blogContainer.isBound(BLOG_SYMBOLS.ICreateArticleController)) { + blogContainer.unbind(BLOG_SYMBOLS.ICreateArticleController); + } + blogContainer + .bind(BLOG_SYMBOLS.IGetArticlesController) + .toConstantValue( + withSpan( + tracer, + { name: "blog.getArticles", op: "controller" }, + getArticlesController(wrappedGetArticles), + ), + ); + blogContainer + .bind(BLOG_SYMBOLS.IGetArticleBySlugController) + .toConstantValue( + withSpan( + tracer, + { name: "blog.getArticleBySlug", op: "controller" }, + getArticleBySlugController(wrappedGetArticleBySlug), + ), + ); + blogContainer + .bind(BLOG_SYMBOLS.ICreateArticleController) + .toConstantValue( + withSpan( + tracer, + { name: "blog.createArticle", op: "controller" }, + createArticleController(wrappedCreateArticle), + ), + ); } diff --git a/packages/blog/src/infrastructure/repositories/articles.repository.mock.ts b/packages/blog/src/infrastructure/repositories/articles.repository.mock.ts index bb8d48d..2ee4bc3 100644 --- a/packages/blog/src/infrastructure/repositories/articles.repository.mock.ts +++ b/packages/blog/src/infrastructure/repositories/articles.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 { IArticlesRepository } from "../../application/repositories/articles.repository.interface"; import type { Article } from "../../entities/models/article"; @@ -7,13 +13,38 @@ import type { Article } from "../../entities/models/article"; @injectable() export class MockArticlesRepository implements IArticlesRepository { private _articles: Article[] = []; + 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 + } async getArticle(id: string): Promise
{ - return this._articles.find((a) => a.id === id); + return this.tracer.startSpan( + { name: "articles.getArticle", op: "repository", attributes: { id } }, + async (span) => { + const found = this._articles.find((a) => a.id === id); + span.setAttribute("found", Boolean(found)); + return found; + }, + ); } async getArticleBySlug(slug: string): Promise
{ - return this._articles.find((a) => a.slug === slug); + return this.tracer.startSpan( + { name: "articles.getArticleBySlug", op: "repository", attributes: { slug } }, + async (span) => { + const found = this._articles.find((a) => a.slug === slug); + span.setAttribute("found", Boolean(found)); + return found; + }, + ); } async getArticles(options?: { @@ -22,33 +53,62 @@ export class MockArticlesRepository implements IArticlesRepository { limit?: number; offset?: number; }): Promise { - let result = [...this._articles]; - if (options?.status) { - result = result.filter((a) => a.status === options.status); - } - if (options?.authorId) { - result = result.filter((a) => a.authorId === options.authorId); - } - const offset = options?.offset ?? 0; - const limit = options?.limit ?? 50; - return result.slice(offset, offset + limit); + return this.tracer.startSpan( + { + name: "articles.getArticles", + op: "repository", + attributes: { + status: options?.status ?? null, + authorId: options?.authorId ?? null, + limit: options?.limit ?? null, + offset: options?.offset ?? null, + }, + }, + async (span) => { + let result = [...this._articles]; + if (options?.status) { + result = result.filter((a) => a.status === options.status); + } + if (options?.authorId) { + result = result.filter((a) => a.authorId === options.authorId); + } + const offset = options?.offset ?? 0; + const limit = options?.limit ?? 50; + const sliced = result.slice(offset, offset + limit); + span.setAttribute("count", sliced.length); + return sliced; + }, + ); } async createArticle(input: Article): Promise
{ - this._articles.push(input); - return input; + return this.tracer.startSpan( + { name: "articles.createArticle", op: "repository", attributes: { slug: input.slug } }, + async (span) => { + this._articles.push(input); + span.setAttribute("id", input.id); + return input; + }, + ); } async updateArticle( id: string, input: Partial
, ): Promise
{ - const index = this._articles.findIndex((a) => a.id === id); - if (index === -1) return undefined; - const current = this._articles[index]; - if (!current) return undefined; - const updated: Article = { ...current, ...input, id: current.id }; - this._articles[index] = updated; - return updated; + return this.tracer.startSpan( + { name: "articles.updateArticle", op: "repository", attributes: { id } }, + async (span) => { + const idx = this._articles.findIndex((a) => a.id === id); + if (idx === -1) { + span.setAttribute("found", false); + return undefined; + } + const merged = { ...this._articles[idx]!, ...input, id } as Article; + this._articles[idx] = merged; + span.setAttribute("found", true); + return merged; + }, + ); } } diff --git a/packages/blog/src/infrastructure/repositories/articles.repository.span.test.ts b/packages/blog/src/infrastructure/repositories/articles.repository.span.test.ts new file mode 100644 index 0000000..f5ed8ab --- /dev/null +++ b/packages/blog/src/infrastructure/repositories/articles.repository.span.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from "vitest"; +import { RecordingTracer, RecordingLogger } from "@repo/core-testing/instrumentation"; +import { MockArticlesRepository } from "@/infrastructure/repositories/articles.repository.mock"; + +// Mock repo also wraps in spans (R42); easier to assert without booting Payload. +describe("MockArticlesRepository emits spans (R42)", () => { + it("getArticles emits one span with op='repository'", async () => { + const tracer = new RecordingTracer(); + const logger = new RecordingLogger(); + const repo = new MockArticlesRepository(tracer, logger); + await repo.getArticles({ limit: 10 }); + expect(tracer.spans).toHaveLength(1); + expect(tracer.spans[0]).toMatchObject({ + name: "articles.getArticles", + op: "repository", + }); + expect(tracer.spans[0]!.attributes).toMatchObject({ limit: 10 }); + }); + + it("createArticle emits a span with slug attribute", async () => { + const tracer = new RecordingTracer(); + const repo = new MockArticlesRepository(tracer); + await repo.createArticle({ + id: "a1", + title: "T", + slug: "t", + content: null, + status: "draft", + authorId: "u1", + createdAt: new Date(), + updatedAt: new Date(), + }); + expect(tracer.findSpan("articles.createArticle")).toBeDefined(); + expect(tracer.findSpan("articles.createArticle")!.attributes.slug).toBe("t"); + }); + + it("getArticle records found=false for missing id", async () => { + const tracer = new RecordingTracer(); + const repo = new MockArticlesRepository(tracer); + await repo.getArticle("missing"); + expect(tracer.spans[0]!.attributes.found).toBe(false); + }); +}); diff --git a/packages/blog/src/infrastructure/repositories/articles.repository.test.ts b/packages/blog/src/infrastructure/repositories/articles.repository.test.ts index f80daa5..adf375e 100644 --- a/packages/blog/src/infrastructure/repositories/articles.repository.test.ts +++ b/packages/blog/src/infrastructure/repositories/articles.repository.test.ts @@ -62,7 +62,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; }, ), @@ -77,7 +80,10 @@ function buildPayloadStub() { overrideAccess?: boolean; }) => { const existing = store.get(String(id)); - if (!existing) throw new Error(`Not found: ${id}`); + if (!existing) { + const err = Object.assign(new Error(`Not found: ${id}`), { status: 404 }); + throw err; + } const updated = { ...existing, ...data }; store.set(String(id), updated); return updated; diff --git a/packages/blog/src/infrastructure/repositories/articles.repository.ts b/packages/blog/src/infrastructure/repositories/articles.repository.ts index bd1095c..b0c7074 100644 --- a/packages/blog/src/infrastructure/repositories/articles.repository.ts +++ b/packages/blog/src/infrastructure/repositories/articles.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 { IArticlesRepository } from "../../application/repositories/articles.repository.interface"; import type { Article } from "../../entities/models/article"; @@ -36,38 +42,83 @@ function mapDoc(doc: PayloadArticleDoc): Article { }; } +const FEATURE = "blog" as const; +const REPO = "articles" as const; + @injectable() export class ArticlesRepository implements IArticlesRepository { 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 getArticle(id: string): Promise
{ - const payload = await getPayload({ config: this.config }); - try { - const doc = await payload.findByID({ - collection: "articles", - id, - overrideAccess: true, - }); - return mapDoc(doc as PayloadArticleDoc); - } catch { - return undefined; - } + return this.tracer.startSpan( + { name: "articles.getArticle", op: "repository", attributes: { id } }, + async (span) => { + try { + const payload = await getPayload({ config: this.config }); + const doc = await payload.findByID({ + collection: "articles", + id, + overrideAccess: true, + }); + span.setAttribute("found", true); + return mapDoc(doc as PayloadArticleDoc); + } catch (err) { + // Payload throws on not-found; treat as undefined per existing semantics + 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: "getArticle" }, + }); + span.setStatus("error", err instanceof Error ? err.message : String(err)); + throw err; + } + }, + ); } async getArticleBySlug(slug: string): Promise
{ - const payload = await getPayload({ config: this.config }); - const result = await payload.find({ - collection: "articles", - where: { slug: { equals: slug } }, - limit: 1, - overrideAccess: true, - }); - const doc = result.docs[0] as PayloadArticleDoc | undefined; - return doc ? mapDoc(doc) : undefined; + return this.tracer.startSpan( + { name: "articles.getArticleBySlug", op: "repository", attributes: { slug } }, + async (span) => { + try { + const payload = await getPayload({ config: this.config }); + const result = await payload.find({ + collection: "articles", + where: { slug: { equals: slug } }, + limit: 1, + overrideAccess: true, + }); + const doc = result.docs[0] as PayloadArticleDoc | undefined; + span.setAttribute("found", Boolean(doc)); + return doc ? mapDoc(doc) : undefined; + } catch (err) { + this.logger.captureException(err, { + tags: { feature: FEATURE, repo: REPO, method: "getArticleBySlug" }, + }); + span.setStatus("error", err instanceof Error ? err.message : String(err)); + throw err; + } + }, + ); } async getArticles(options?: { @@ -76,60 +127,116 @@ export class ArticlesRepository implements IArticlesRepository { limit?: number; offset?: number; }): Promise { - const payload = await getPayload({ config: this.config }); - const where: Record = {}; - if (options?.status) where.status = { equals: options.status }; - if (options?.authorId) where.author = { equals: options.authorId }; + return this.tracer.startSpan( + { + name: "articles.getArticles", + op: "repository", + attributes: { + status: options?.status ?? null, + authorId: options?.authorId ?? null, + limit: options?.limit ?? null, + offset: options?.offset ?? null, + }, + }, + async (span) => { + try { + const payload = await getPayload({ config: this.config }); + const where: Record = {}; + if (options?.status) where.status = { equals: options.status }; + if (options?.authorId) where.author = { equals: options.authorId }; - const result = await payload.find({ - collection: "articles", - where: where as never, - limit: options?.limit ?? 50, - page: options?.offset - ? Math.floor(options.offset / (options.limit ?? 50)) + 1 - : 1, - overrideAccess: true, - }); - return result.docs.map((d) => mapDoc(d as PayloadArticleDoc)); + const result = await payload.find({ + collection: "articles", + where: where as never, + limit: options?.limit ?? 50, + page: options?.offset + ? Math.floor(options.offset / (options.limit ?? 50)) + 1 + : 1, + overrideAccess: true, + }); + span.setAttribute("count", result.docs.length); + return result.docs.map((d) => mapDoc(d as PayloadArticleDoc)); + } catch (err) { + this.logger.captureException(err, { + tags: { feature: FEATURE, repo: REPO, method: "getArticles" }, + }); + span.setStatus("error", err instanceof Error ? err.message : String(err)); + throw err; + } + }, + ); } async createArticle(input: Article): Promise
{ - const payload = await getPayload({ config: this.config }); - const created = await payload.create({ - collection: "articles", - data: { - title: input.title, - slug: input.slug, - content: input.content, - status: input.status, - author: input.authorId, - } as never, - overrideAccess: true, - }); - return mapDoc(created as PayloadArticleDoc); + return this.tracer.startSpan( + { name: "articles.createArticle", op: "repository", attributes: { slug: input.slug } }, + async (span) => { + try { + const payload = await getPayload({ config: this.config }); + const created = await payload.create({ + collection: "articles", + data: { + title: input.title, + slug: input.slug, + content: input.content, + status: input.status, + author: input.authorId, + } as never, + overrideAccess: true, + }); + span.setAttribute("id", String((created as PayloadArticleDoc).id)); + return mapDoc(created as PayloadArticleDoc); + } catch (err) { + this.logger.captureException(err, { + tags: { feature: FEATURE, repo: REPO, method: "createArticle" }, + }); + span.setStatus("error", err instanceof Error ? err.message : String(err)); + throw err; + } + }, + ); } async updateArticle( id: string, input: Partial
, ): Promise
{ - const payload = await getPayload({ config: this.config }); - try { - const updated = await payload.update({ - collection: "articles", - id, - data: { - ...(input.title !== undefined && { title: input.title }), - ...(input.slug !== undefined && { slug: input.slug }), - ...(input.content !== undefined && { content: input.content }), - ...(input.status !== undefined && { status: input.status }), - ...(input.authorId !== undefined && { author: input.authorId }), - } as never, - overrideAccess: true, - }); - return mapDoc(updated as PayloadArticleDoc); - } catch { - return undefined; - } + return this.tracer.startSpan( + { name: "articles.updateArticle", op: "repository", attributes: { id } }, + async (span) => { + try { + const payload = await getPayload({ config: this.config }); + const updated = await payload.update({ + collection: "articles", + id, + data: { + ...(input.title !== undefined && { title: input.title }), + ...(input.slug !== undefined && { slug: input.slug }), + ...(input.content !== undefined && { content: input.content }), + ...(input.status !== undefined && { status: input.status }), + ...(input.authorId !== undefined && { author: input.authorId }), + } as never, + overrideAccess: true, + }); + span.setAttribute("found", true); + return mapDoc(updated as PayloadArticleDoc); + } 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: "updateArticle" }, + }); + span.setStatus("error", err instanceof Error ? err.message : String(err)); + throw err; + } + }, + ); } }