docs(html): update §06 + §08 with verified layer-by-layer instrumentation usage
After the user pushed back on intent-vs-shipped state, I greped each layer and updated both explainers to reflect actual code, not documented intent. data-flow-explainer.html §06 — "Tracing & error capture": - New "Where instrumentation actually lives" table — per-layer breakdown of inline span / inline capture / composed-via-wrapper, with a verifiable grep showing only repos have inline calls. - New "The wrapper sandwich" code block showing the actual repo body shape next to the bind-time withSpan(withCapture(...)) composition. - Capture-rules table refined to reflect the R44 fix that just landed: use cases capture business-rule errors and output-schema failures (not bubbled-from-repo); controllers capture safeParse failures (not bubbled-from-use-case); the __sentryReported flag is what makes this safe. - Double-report-guard paragraph now mentions withCapture, SentryLogger, and RecordingLogger all check the flag (not just SentryLogger). di-explainer.html §08 — "Instrumentation symbols": - Wiring path updated from "withSpan(tracer, ...)" to "withSpan(withCapture(...))" to reflect the post-merge wiring. - New "Two wrappers, applied as a sandwich" table comparing what each wrapper does and where it fires; closing note that repos can't use the wrapper because they own per-call attributes. Also bundled: a 1-line aesthetic SVG noise tweak in di-explainer.html (opacity='0.25', baseFrequency 0.85→0.95) that was sitting in the working tree before this session — preserved across the Plan 10 merge via stash/pop and now committed alongside the doc update. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1438,6 +1438,13 @@ footer .colophon {
|
|||||||
font-family: "Fraunces", serif;
|
font-family: "Fraunces", serif;
|
||||||
font-weight: 500;
|
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 {
|
.pii-rules {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
@@ -2366,11 +2373,98 @@ footer .colophon {
|
|||||||
<h3 class="trace-h3">The trace tree (one tRPC request)</h3>
|
<h3 class="trace-h3">The trace tree (one tRPC request)</h3>
|
||||||
<pre class="trace-tree"><code>HTTP transaction (auto, @sentry/nextjs)
|
<pre class="trace-tree"><code>HTTP transaction (auto, @sentry/nextjs)
|
||||||
└── tRPC procedure span (auto, sentry trpc integration)
|
└── tRPC procedure span (auto, sentry trpc integration)
|
||||||
└── controller span (op="controller", DI-wrapped)
|
└── controller span (op="controller", composed at DI bind time)
|
||||||
└── use-case span (op="use-case", DI-wrapped)
|
└── use-case span (op="use-case", composed at DI bind time)
|
||||||
└── repository span (op="repository", explicit startSpan)
|
└── repository span (op="repository", inline per method)
|
||||||
└── Payload Local API call (auto, @sentry/node http)</code></pre>
|
└── Payload Local API call (auto, @sentry/node http)</code></pre>
|
||||||
|
|
||||||
|
<h3 class="trace-h3">Where instrumentation actually lives</h3>
|
||||||
|
<p class="trace-p">Two ways spans + captures get attached. <strong>Inline</strong> means the call appears in the layer's own body. <strong>Composed-in</strong> means a higher-order wrapper applied at DI bind time — the body stays vendor-clean.</p>
|
||||||
|
<table class="capture-rules instrumentation-where">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Layer</th><th>Span</th><th>Capture</th><th>How</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>Use case body</td>
|
||||||
|
<td>—</td>
|
||||||
|
<td>—</td>
|
||||||
|
<td>Composed: <code>withSpan(withCapture(useCase(deps)))</code> in <code>bind-production.ts</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Controller body</td>
|
||||||
|
<td>—</td>
|
||||||
|
<td>—</td>
|
||||||
|
<td>Composed: <code>withSpan(withCapture(controller(uc)))</code> in <code>bind-production.ts</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Repository (real)</td>
|
||||||
|
<td><strong>Inline</strong> per method</td>
|
||||||
|
<td><strong>Inline</strong> in <code>catch</code></td>
|
||||||
|
<td><code>this.tracer.startSpan(...)</code> + <code>this.logger.captureException(...)</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Repository (mock)</td>
|
||||||
|
<td><strong>Inline</strong> per method</td>
|
||||||
|
<td>—</td>
|
||||||
|
<td>Span shape parity with real; mocks don't originate infra errors</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>tRPC procedure</td>
|
||||||
|
<td>Auto (SDK)</td>
|
||||||
|
<td>—</td>
|
||||||
|
<td>Sentry's tRPC integration — no code in this repo</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>defineErrorMiddleware</code></td>
|
||||||
|
<td>—</td>
|
||||||
|
<td>—</td>
|
||||||
|
<td>Maps domain errors → TRPCError. Never captures (R44 boundary)</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p class="trace-p"><strong>Verifiable:</strong> <code>grep -rn "this.tracer\|this.logger" packages/*/src</code> returns hits only in <code>infrastructure/repositories/*.repository.ts</code> and <code>*.repository.mock.ts</code>. Use case and controller bodies have zero matches. <code>withSpan</code> / <code>withCapture</code> appear only in <code>di/bind-*.ts</code> files.</p>
|
||||||
|
|
||||||
|
<h3 class="trace-h3">The wrapper sandwich (one feature, in <code>bind-production.ts</code>)</h3>
|
||||||
|
<pre class="trace-tree"><code>// 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),
|
||||||
|
),
|
||||||
|
);</code></pre>
|
||||||
|
<p class="trace-p"><strong>Order matters.</strong> <code>withSpan</code> is outermost so the errored span's timing reflects the captured-and-rethrown failure. <code>withCapture</code> is between span and factory so the error is captured <em>before</em> the span closes with error status.</p>
|
||||||
|
|
||||||
<h3 class="trace-h3">Capture rules (where <code>captureException</code> fires)</h3>
|
<h3 class="trace-h3">Capture rules (where <code>captureException</code> fires)</h3>
|
||||||
<table class="capture-rules">
|
<table class="capture-rules">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -2380,17 +2474,17 @@ footer .colophon {
|
|||||||
<tr>
|
<tr>
|
||||||
<td>Repository</td>
|
<td>Repository</td>
|
||||||
<td>Infra / Payload errors that originate here</td>
|
<td>Infra / Payload errors that originate here</td>
|
||||||
<td>Bubbled errors</td>
|
<td>Bubbled errors (already captured downstream)</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Use case</td>
|
<td>Use case</td>
|
||||||
<td>Business-rule violations originated in this body</td>
|
<td>Business-rule violations originated in this body (e.g. <code>AuthenticationError</code>) and output-schema validation failures</td>
|
||||||
<td>Errors from repos (already captured)</td>
|
<td>Errors from repos — flag is set, <code>withCapture</code> bails</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Controller</td>
|
<td>Controller</td>
|
||||||
<td><code>InputParseError</code> from safeParse failure</td>
|
<td><code>InputParseError</code> from <code>safeParse</code> failure</td>
|
||||||
<td>Anything else</td>
|
<td>Errors from use cases — flag is set, <code>withCapture</code> bails</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>defineErrorMiddleware</code></td>
|
<td><code>defineErrorMiddleware</code></td>
|
||||||
@@ -2401,7 +2495,7 @@ footer .colophon {
|
|||||||
</table>
|
</table>
|
||||||
|
|
||||||
<h3 class="trace-h3">Double-report guard</h3>
|
<h3 class="trace-h3">Double-report guard</h3>
|
||||||
<p class="trace-p">Every error captured by <code>SentryLogger.captureException</code> gets a non-enumerable <code>__sentryReported = true</code> 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.</p>
|
<p class="trace-p">Each error gets a non-enumerable <code>__sentryReported</code> flag the first time it's captured. <code>withCapture</code>, <code>SentryLogger</code>, and <code>RecordingLogger</code> all check the flag and bail if it's set. So an error bubbling repo → use-case → controller surfaces in the logger <strong>exactly once</strong>, with the inner-most layer's tags. Helper lives in <code>core-shared/instrumentation/reported-flag.ts</code>.</p>
|
||||||
|
|
||||||
<h3 class="trace-h3">PII rules (R31–R38, non-negotiable)</h3>
|
<h3 class="trace-h3">PII rules (R31–R38, non-negotiable)</h3>
|
||||||
<ul class="pii-rules">
|
<ul class="pii-rules">
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ body {
|
|||||||
body::before {
|
body::before {
|
||||||
content: "";
|
content: "";
|
||||||
position: fixed; inset: 0;
|
position: fixed; inset: 0;
|
||||||
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 200 200'><filter id='n'><feTurbulence baseFrequency='0.85' numOctaves='3'/><feColorMatrix values='0 0 0 0 0.10 0 0 0 0 0.07 0 0 0 0 0.05 0 0 0 0 0.10 0'/></filter><rect width='200' height='200' filter='url(%23n)'/></svg>");
|
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 200 200' opacity='0.25'><filter id='n'><feTurbulence baseFrequency='0.95' numOctaves='3'/><feColorMatrix values='0 0 0 0 0.10 0 0 0 0 0.07 0 0 0 0 0.05 0 0 0 0 0.10 0'/></filter><rect width='200' height='200' filter='url(%23n)'/></svg>");
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
opacity: 0.45;
|
opacity: 0.45;
|
||||||
mix-blend-mode: multiply;
|
mix-blend-mode: multiply;
|
||||||
@@ -1185,11 +1185,32 @@ footer .colophon {
|
|||||||
└─ Noop or Sentry binders ← bind to sharedContainer
|
└─ Noop or Sentry binders ← bind to sharedContainer
|
||||||
└─ bindProductionX(config, tracer, logger)
|
└─ bindProductionX(config, tracer, logger)
|
||||||
└─ feature container also binds TRACER + LOGGER
|
└─ feature container also binds TRACER + LOGGER
|
||||||
└─ withSpan(tracer, ...) at every use case + controller</code></pre>
|
└─ withSpan(withCapture(...)) at every use case + controller</code></pre>
|
||||||
|
|
||||||
<p class="trace-p"><strong>Why per-feature containers also get the binding:</strong> repository classes resolve TRACER/LOGGER through the container; controllers and use cases receive instrumentation via the bind-time wrapper instead.</p>
|
<p class="trace-p"><strong>Why per-feature containers also get the binding:</strong> repository classes resolve TRACER/LOGGER through the container; controllers and use cases receive instrumentation via the bind-time wrapper instead.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<h3 class="trace-h3" style="margin-top: 36px;">Two wrappers, applied as a sandwich</h3>
|
||||||
|
<p class="trace-p"><code>withSpan</code> and <code>withCapture</code> are higher-order functions that take a <code>(args) => Promise<R></code> and return the same shape. The binders compose them: <code>withSpan(withCapture(factory(deps)))</code>. Span is outermost so an errored span's timing reflects the capture-and-rethrow.</p>
|
||||||
|
<table class="capture-rules">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Wrapper</th><th>What it does</th><th>Where it fires</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><code>withSpan(tracer, opts, fn)</code></td>
|
||||||
|
<td>Calls <code>tracer.startSpan(opts, () => fn(...))</code>. Pure delegation — no error handling of its own; status-on-error logic lives in the tracer impl.</td>
|
||||||
|
<td>Around every use case + controller, at DI bind time</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>withCapture(logger, tags, fn)</code></td>
|
||||||
|
<td>On throw: checks <code>__sentryReported</code>; if not set, calls <code>logger.captureException(err, { tags })</code>, marks the flag, re-throws. If already set, just re-throws.</td>
|
||||||
|
<td>Around every use case + controller, inside the span wrapper</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p class="trace-p">Repositories are different — they call <code>this.tracer.startSpan</code> + <code>this.logger.captureException</code> inline per method, because they own the per-call attributes (count, IDs, slugs) that the wrapper has no way to know.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
Reference in New Issue
Block a user