diff --git a/docs/architecture/data-flow-explainer.html b/docs/architecture/data-flow-explainer.html index 8f6752c..fd42fc3 100644 --- a/docs/architecture/data-flow-explainer.html +++ b/docs/architecture/data-flow-explainer.html @@ -1438,6 +1438,13 @@ footer .colophon { font-family: "Fraunces", serif; font-weight: 500; } +.capture-rules.instrumentation-where th:nth-child(2), +.capture-rules.instrumentation-where th:nth-child(3), +.capture-rules.instrumentation-where td:nth-child(2), +.capture-rules.instrumentation-where td:nth-child(3) { + white-space: nowrap; + width: 1%; +} .pii-rules { display: grid; gap: 0.5rem; @@ -2366,11 +2373,98 @@ footer .colophon {
HTTP transaction (auto, @sentry/nextjs)
└── tRPC procedure span (auto, sentry trpc integration)
- └── controller span (op="controller", DI-wrapped)
- └── use-case span (op="use-case", DI-wrapped)
- └── repository span (op="repository", explicit startSpan)
+ └── controller span (op="controller", composed at DI bind time)
+ └── use-case span (op="use-case", composed at DI bind time)
+ └── repository span (op="repository", inline per method)
└── Payload Local API call (auto, @sentry/node http)
+ Two ways spans + captures get attached. Inline means the call appears in the layer's own body. Composed-in means a higher-order wrapper applied at DI bind time — the body stays vendor-clean.
+| Layer | Span | Capture | How |
|---|---|---|---|
| Use case body | +— | +— | +Composed: withSpan(withCapture(useCase(deps))) in bind-production.ts |
+
| Controller body | +— | +— | +Composed: withSpan(withCapture(controller(uc))) in bind-production.ts |
+
| Repository (real) | +Inline per method | +Inline in catch |
+ this.tracer.startSpan(...) + this.logger.captureException(...) |
+
| Repository (mock) | +Inline per method | +— | +Span shape parity with real; mocks don't originate infra errors | +
| tRPC procedure | +Auto (SDK) | +— | +Sentry's tRPC integration — no code in this repo | +
defineErrorMiddleware |
+ — | +— | +Maps domain errors → TRPCError. Never captures (R44 boundary) | +
Verifiable: grep -rn "this.tracer\|this.logger" packages/*/src returns hits only in infrastructure/repositories/*.repository.ts and *.repository.mock.ts. Use case and controller bodies have zero matches. withSpan / withCapture appear only in di/bind-*.ts files.
bind-production.ts)// Repository — inline, per public method
+class ArticlesRepository {
+ async getArticles(input) {
+ return this.tracer.startSpan(
+ { name: "articles.getArticles", op: "repository", attributes: { /* ... */ } },
+ async (span) => {
+ try {
+ const result = await /* payload op */;
+ span.setAttribute("count", result.length);
+ return result;
+ } catch (err) {
+ this.logger.captureException(err, {
+ tags: { feature: "blog", repo: "articles", method: "getArticles" },
+ });
+ span.setStatus("error", String(err));
+ throw err;
+ }
+ },
+ );
+ }
+}
+
+// Use cases + controllers — composed at bind time, body stays clean
+const wrappedUC = withSpan(
+ tracer, { name: "blog.getArticles", op: "use-case" },
+ withCapture(
+ logger, { feature: "blog", layer: "use-case", name: "blog.getArticles" },
+ getArticlesUseCase(repo),
+ ),
+);
+const wrappedCtrl = withSpan(
+ tracer, { name: "blog.getArticles", op: "controller" },
+ withCapture(
+ logger, { feature: "blog", layer: "controller", name: "blog.getArticles" },
+ getArticlesController(wrappedUC),
+ ),
+);
+ Order matters. withSpan is outermost so the errored span's timing reflects the captured-and-rethrown failure. withCapture is between span and factory so the error is captured before the span closes with error status.
captureException fires)| Repository | Infra / Payload errors that originate here | -Bubbled errors | +Bubbled errors (already captured downstream) | |
| Use case | -Business-rule violations originated in this body | -Errors from repos (already captured) | +Business-rule violations originated in this body (e.g. AuthenticationError) and output-schema validation failures |
+ Errors from repos — flag is set, withCapture bails |
| Controller | -InputParseError from safeParse failure |
- Anything else | +InputParseError from safeParse failure |
+ Errors from use cases — flag is set, withCapture bails |
defineErrorMiddleware |
@@ -2401,7 +2495,7 @@ footer .colophon {
Every error captured by SentryLogger.captureException gets a non-enumerable __sentryReported = true property. A second capture call for the same error returns early. This means each error surfaces in Sentry exactly once, regardless of how many layers it passes through.
Each error gets a non-enumerable __sentryReported flag the first time it's captured. withCapture, SentryLogger, and RecordingLogger all check the flag and bail if it's set. So an error bubbling repo → use-case → controller surfaces in the logger exactly once, with the inner-most layer's tags. Helper lives in core-shared/instrumentation/reported-flag.ts.
Why per-feature containers also get the binding: repository classes resolve TRACER/LOGGER through the container; controllers and use cases receive instrumentation via the bind-time wrapper instead.
+ +withSpan and withCapture are higher-order functions that take a (args) => Promise<R> and return the same shape. The binders compose them: withSpan(withCapture(factory(deps))). Span is outermost so an errored span's timing reflects the capture-and-rethrow.
| Wrapper | What it does | Where it fires |
|---|---|---|
withSpan(tracer, opts, fn) |
+ Calls tracer.startSpan(opts, () => fn(...)). Pure delegation — no error handling of its own; status-on-error logic lives in the tracer impl. |
+ Around every use case + controller, at DI bind time | +
withCapture(logger, tags, fn) |
+ On throw: checks __sentryReported; if not set, calls logger.captureException(err, { tags }), marks the flag, re-throws. If already set, just re-throws. |
+ Around every use case + controller, inside the span wrapper | +
Repositories are different — they call this.tracer.startSpan + this.logger.captureException inline per method, because they own the per-call attributes (count, IDs, slugs) that the wrapper has no way to know.