From a324da09c53cd6d9dc7e97e70211c46e7e6a90c0 Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Wed, 6 May 2026 23:45:05 +0200 Subject: [PATCH] feat(core-shared): SentryTracer adapter --- packages/core-shared/package.json | 1 + .../sentry/sentry-tracer.test.ts | 69 +++++++++++++++++++ .../instrumentation/sentry/sentry-tracer.ts | 28 ++++++++ 3 files changed, 98 insertions(+) create mode 100644 packages/core-shared/src/instrumentation/sentry/sentry-tracer.test.ts create mode 100644 packages/core-shared/src/instrumentation/sentry/sentry-tracer.ts diff --git a/packages/core-shared/package.json b/packages/core-shared/package.json index e79ffce..6f559d6 100644 --- a/packages/core-shared/package.json +++ b/packages/core-shared/package.json @@ -18,6 +18,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@sentry/nextjs": "^10.51.0", "@trpc/server": "^11.0.0", "payload": "^3.14.0", "superjson": "^2.2.1", diff --git a/packages/core-shared/src/instrumentation/sentry/sentry-tracer.test.ts b/packages/core-shared/src/instrumentation/sentry/sentry-tracer.test.ts new file mode 100644 index 0000000..769a321 --- /dev/null +++ b/packages/core-shared/src/instrumentation/sentry/sentry-tracer.test.ts @@ -0,0 +1,69 @@ +// packages/core-shared/src/instrumentation/sentry/sentry-tracer.test.ts +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@sentry/nextjs", () => ({ + startSpan: vi.fn((_opts: unknown, fn: (span: unknown) => unknown) => + fn({ setAttribute: vi.fn(), setStatus: vi.fn() }), + ), +})); + +import * as Sentry from "@sentry/nextjs"; +import { SentryTracer } from "@/instrumentation/sentry/sentry-tracer"; + +describe("SentryTracer", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("delegates startSpan to @sentry/nextjs.startSpan", async () => { + const tracer = new SentryTracer(); + const result = await tracer.startSpan( + { name: "blog.getArticles", op: "use-case" }, + async () => "value", + ); + expect(result).toBe("value"); + expect(Sentry.startSpan).toHaveBeenCalledTimes(1); + expect((Sentry.startSpan as ReturnType).mock.calls[0][0]).toMatchObject({ + name: "blog.getArticles", + op: "use-case", + }); + }); + + it("forwards attributes to Sentry", async () => { + const tracer = new SentryTracer(); + await tracer.startSpan( + { name: "articles.findAll", op: "repository", attributes: { collection: "articles", limit: 10 } }, + async () => undefined, + ); + expect( + (Sentry.startSpan as ReturnType).mock.calls[0][0].attributes, + ).toEqual({ + collection: "articles", + limit: 10, + }); + }); + + it("propagates errors from the wrapped function", async () => { + const tracer = new SentryTracer(); + await expect( + tracer.startSpan({ name: "x" }, async () => { + throw new Error("boom"); + }), + ).rejects.toThrow("boom"); + }); + + it("ISpan adapter forwards setAttribute and setStatus to Sentry's span", async () => { + const sentrySpan = { setAttribute: vi.fn(), setStatus: vi.fn() }; + (Sentry.startSpan as ReturnType).mockImplementationOnce( + (_opts: unknown, fn: (span: unknown) => unknown) => fn(sentrySpan), + ); + const tracer = new SentryTracer(); + await tracer.startSpan({ name: "x" }, async (span) => { + span.setAttribute("k", "v"); + span.setStatus("error", "msg"); + return undefined; + }); + expect(sentrySpan.setAttribute).toHaveBeenCalledWith("k", "v"); + expect(sentrySpan.setStatus).toHaveBeenCalled(); + }); +}); diff --git a/packages/core-shared/src/instrumentation/sentry/sentry-tracer.ts b/packages/core-shared/src/instrumentation/sentry/sentry-tracer.ts new file mode 100644 index 0000000..322c23d --- /dev/null +++ b/packages/core-shared/src/instrumentation/sentry/sentry-tracer.ts @@ -0,0 +1,28 @@ +// packages/core-shared/src/instrumentation/sentry/sentry-tracer.ts +import * as Sentry from "@sentry/nextjs"; +import type { ITracer, ISpan, SpanOpts } from "../tracer.interface"; + +export class SentryTracer implements ITracer { + async startSpan(opts: SpanOpts, fn: (span: ISpan) => Promise): Promise { + return Sentry.startSpan( + { + name: opts.name, + op: opts.op, + attributes: opts.attributes, + }, + async (sentrySpan) => { + const adapter: ISpan = { + setAttribute(key, value) { + sentrySpan?.setAttribute?.(key, value as string | number | boolean); + }, + setStatus(status, message) { + // Sentry v8+ uses { code: number, message?: string }; we map our enum + const code = status === "ok" ? 1 : 2; + sentrySpan?.setStatus?.({ code, message }); + }, + }; + return fn(adapter); + }, + ); + } +}