@open-mercato/telemetry 0.7.1-develop.7182.1.789943f937 → 0.7.1-develop.7185.1.0f280ef1f1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +26 -0
- package/README.md +69 -1
- package/dist/facade/report-error.js +45 -13
- package/dist/facade/report-error.js.map +2 -2
- package/package.json +2 -2
- package/src/__tests__/report-error.test.ts +242 -0
- package/src/facade/report-error.ts +116 -16
- package/src/types.ts +25 -0
package/AGENTS.md
CHANGED
|
@@ -29,6 +29,24 @@ off by default. Spec:
|
|
|
29
29
|
- Keep metric labels low-cardinality. Tenant, organization, and user IDs belong
|
|
30
30
|
on span attributes, never metric labels.
|
|
31
31
|
- Apply redaction at the provider boundary as well as at facade call sites.
|
|
32
|
+
- Report every recorded error: a `catch` that does anything other than rethrow
|
|
33
|
+
(persists a row, sets a `failed` status, dead-letters an item, returns a
|
|
34
|
+
fallback) MUST also reach `reportError` — directly, or through a chokepoint that
|
|
35
|
+
does (`integrationLogService.write` at `level: 'error'`, the queue failure
|
|
36
|
+
paths). `logger.error` alone does NOT satisfy this: no span exception, no
|
|
37
|
+
`om.errors` sample, no fingerprint. Full policy:
|
|
38
|
+
`apps/docs/docs/framework/runtime/error-reporting.mdx`.
|
|
39
|
+
- Pass `code` on every `reportError` call: a stable, enumerated `module.reason`
|
|
40
|
+
token, never an interpolated string. It is a metric label and the fingerprint
|
|
41
|
+
backends group on — ids go in `attributes`. The funnel narrows it through
|
|
42
|
+
`groupableCode` from `@open-mercato/shared/lib/telemetry/error-code` and DROPS
|
|
43
|
+
anything off-shape, because metric labels skip redaction; a chokepoint taking a
|
|
44
|
+
`code` from outside the framework (an adapter's `data.errorCode`, a module's
|
|
45
|
+
`integrationLogService.write({ code })`) narrows it with its OWN fallback first,
|
|
46
|
+
so the error still lands in a group rather than none.
|
|
47
|
+
- Put the CAUSE in the reported message. A constant message with the reason only
|
|
48
|
+
in `payload` reports an error nobody can act on, because the payload stays in
|
|
49
|
+
the database.
|
|
32
50
|
|
|
33
51
|
## Ask First
|
|
34
52
|
|
|
@@ -43,6 +61,14 @@ off by default. Spec:
|
|
|
43
61
|
|
|
44
62
|
- Never emit PII, credentials, record content, SQL parameters, request bodies,
|
|
45
63
|
or arbitrary thrown-object properties.
|
|
64
|
+
The integration-log tee reports a row's message, `code` and ids only —
|
|
65
|
+
`integration_logs.payload` never leaves the database.
|
|
66
|
+
- Never add sampling, throttling or suppression to `reportError`. Volume belongs
|
|
67
|
+
to the collector and the backend, which drop where the drop is visible; a
|
|
68
|
+
facade-level limiter is the only one that loses an error at the source.
|
|
69
|
+
- Never call a provider-supplied hook unguarded from the facade. `reportError`
|
|
70
|
+
runs inside `catch` blocks that still have to rethrow or return a 500, so a
|
|
71
|
+
third-party sink that throws must degrade to a warning, not escape.
|
|
46
72
|
- Never trust `traceparent` or `x-original-traceparent` at an inbound/global
|
|
47
73
|
boundary unless `TELEMETRY_TRUST_INBOUND_TRACE=true`.
|
|
48
74
|
- Never store provider, shared-logger extension, or runtime bridge state only in
|
package/README.md
CHANGED
|
@@ -128,7 +128,7 @@ bridge, so the package is not loaded on the disabled path.
|
|
|
128
128
|
| `withSpan(name, fn, opts?)` | Run `fn` in a provider-owned span. `opts.root` starts a new trace; `opts.links` attaches causal links (see [Long-lived jobs](#long-lived-jobs-root-spans)) |
|
|
129
129
|
| `currentSpan()` / `setAttributes(attrs)` | Active span access |
|
|
130
130
|
| `counter` / `histogram` / `gauge` | Metric helpers |
|
|
131
|
-
| `reportError(err, ctx?)` | Span exception + shared error log + `om.errors` |
|
|
131
|
+
| `reportError(err, ctx?)` | Span exception + shared error log + `om.errors{module, error.code}` + the provider's own error sink. Pass `ctx.code` — see [Error reporting](#error-reporting) |
|
|
132
132
|
| `captureTraceContext()` / `continueTrace(...)` | Dedicated cross-boundary propagation |
|
|
133
133
|
| `initTelemetry()` / `shutdownTelemetry()` | Opt-in bootstrap and flush |
|
|
134
134
|
| `registerProvider(provider)` | Register a custom provider for an enabled backend name |
|
|
@@ -138,6 +138,74 @@ bridge, so the package is not loaded on the disabled path.
|
|
|
138
138
|
`@open-mercato/telemetry/nextjs-config` separately exports only
|
|
139
139
|
`telemetryServerExternalPackages` for build configuration.
|
|
140
140
|
|
|
141
|
+
### Error reporting
|
|
142
|
+
|
|
143
|
+
`reportError` is the error funnel, and the policy around it is one rule:
|
|
144
|
+
**recording an error is not reporting it.** A `catch` that persists a row, sets a
|
|
145
|
+
`failed` status or dead-letters an item MUST also report — `logger.error` alone
|
|
146
|
+
gives you a log record with no span exception, no `om.errors` sample and no
|
|
147
|
+
fingerprint.
|
|
148
|
+
|
|
149
|
+
```ts
|
|
150
|
+
reportError(error, {
|
|
151
|
+
module: 'data_sync',
|
|
152
|
+
code: 'data_sync.item_failed', // stable, enumerated `module.reason`
|
|
153
|
+
attributes: { runId, integrationId },
|
|
154
|
+
})
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
`code` is a metric label and the key backends group on, so it MUST NOT be
|
|
158
|
+
interpolated (`` `failed for ${id}` `` breaks grouping and metric cardinality —
|
|
159
|
+
ids go in `attributes`). Every reported error is emitted: this facade adds no
|
|
160
|
+
sampling or throttling, because the collector and the backend already own volume
|
|
161
|
+
and drop it where an operator can see and adjust the drop.
|
|
162
|
+
|
|
163
|
+
The full policy, the framework chokepoints that report for you, and the
|
|
164
|
+
verification recipe live in
|
|
165
|
+
`apps/docs/docs/framework/runtime/error-reporting.mdx`.
|
|
166
|
+
|
|
167
|
+
#### A provider with its own error sink (Sentry-shaped backends)
|
|
168
|
+
|
|
169
|
+
`TelemetryProvider.reportError?()` is optional and called **in addition to** the
|
|
170
|
+
span/log/metric path, so implementing it can never make signal disappear — the
|
|
171
|
+
provider owns its own de-duplication. Nothing vendor-specific ships upstream;
|
|
172
|
+
plug it in from your app's bootstrap:
|
|
173
|
+
|
|
174
|
+
```ts
|
|
175
|
+
import { registerProvider, initTelemetry } from '@open-mercato/telemetry'
|
|
176
|
+
import type { TelemetryProvider } from '@open-mercato/telemetry'
|
|
177
|
+
|
|
178
|
+
const sentryProvider: TelemetryProvider = {
|
|
179
|
+
...tracingDelegate, // reuse an OTLP/console provider for spans, or no-op them
|
|
180
|
+
name: 'sentry', // must equal TELEMETRY_BACKEND
|
|
181
|
+
supports: ['errors', 'logs'],
|
|
182
|
+
reportError(error, { module, code, attributes }) {
|
|
183
|
+
Sentry.captureException(new Error(error.message), {
|
|
184
|
+
fingerprint: code ? [code] : undefined,
|
|
185
|
+
tags: { module, code },
|
|
186
|
+
extra: attributes,
|
|
187
|
+
})
|
|
188
|
+
},
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
registerProvider(sentryProvider)
|
|
192
|
+
await initTelemetry()
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
`error` arrives serialized and PII-redacted (name, message, stack) and
|
|
196
|
+
`attributes` already redacted — never reach for the original thrown value.
|
|
197
|
+
|
|
198
|
+
**The hook MUST NOT throw.** `reportError` is called from `catch` blocks that
|
|
199
|
+
still have work to do after it — rethrowing the original error, returning a 500
|
|
200
|
+
with its correlation header — so a hook that throws would replace the caller's
|
|
201
|
+
error with yours. The facade wraps the call and degrades a throwing hook to a
|
|
202
|
+
warning, but do not rely on that: swallow your SDK's failures inside the
|
|
203
|
+
implementation, where you can decide what a dropped report means.
|
|
204
|
+
|
|
205
|
+
`code` reaches your hook as `context.code`. It is deliberately **not** repeated in
|
|
206
|
+
`attributes`, so putting it in both `tags` and `extra`, as the snippet above does
|
|
207
|
+
with `tags`, is your choice rather than an accident of the payload.
|
|
208
|
+
|
|
141
209
|
### Long-lived jobs: root spans
|
|
142
210
|
|
|
143
211
|
Trace context propagates from the request that triggered a job through the queue
|
|
@@ -1,24 +1,56 @@
|
|
|
1
1
|
import { createLogger } from "@open-mercato/shared/lib/logger";
|
|
2
|
+
import { groupableCode } from "@open-mercato/shared/lib/telemetry/error-code";
|
|
2
3
|
import { currentSpan } from "./tracer.js";
|
|
3
4
|
import { serializeError } from "./serialize.js";
|
|
4
5
|
import { counter } from "./meter.js";
|
|
5
6
|
import { redactAttributes } from "./redact.js";
|
|
7
|
+
import { getActiveProvider } from "../provider/registry.js";
|
|
6
8
|
const logger = createLogger("telemetry");
|
|
9
|
+
const CODE_ATTRIBUTE = "error.code";
|
|
10
|
+
let reporting = false;
|
|
7
11
|
function reportError(error, ctx) {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
+
if (reporting) return;
|
|
13
|
+
reporting = true;
|
|
14
|
+
try {
|
|
15
|
+
const code = groupableCode(ctx?.code);
|
|
16
|
+
const span = currentSpan();
|
|
17
|
+
if (span) {
|
|
18
|
+
span.recordException(error);
|
|
19
|
+
span.setStatus("error");
|
|
20
|
+
if (code) span.setAttribute(CODE_ATTRIBUTE, code);
|
|
21
|
+
}
|
|
22
|
+
const serialized = serializeError(error);
|
|
23
|
+
const attributes = { ...ctx?.attributes ?? {} };
|
|
24
|
+
if (ctx?.module) attributes.module = ctx.module;
|
|
25
|
+
if (code) attributes[CODE_ATTRIBUTE] = code;
|
|
26
|
+
const safeAttributes = redactAttributes(attributes);
|
|
27
|
+
const safeError = new Error(serialized.message);
|
|
28
|
+
safeError.name = serialized.name;
|
|
29
|
+
safeError.stack = serialized.stack;
|
|
30
|
+
logger.error("Application error reported", { ...safeAttributes, err: safeError });
|
|
31
|
+
counter("om.errors", 1, errorLabels(ctx?.module, code));
|
|
32
|
+
reportToProvider(serialized, ctx?.module, code, safeAttributes);
|
|
33
|
+
} finally {
|
|
34
|
+
reporting = false;
|
|
12
35
|
}
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
36
|
+
}
|
|
37
|
+
function reportToProvider(serialized, module, code, safeAttributes) {
|
|
38
|
+
try {
|
|
39
|
+
const provider = getActiveProvider();
|
|
40
|
+
if (!provider.reportError) return;
|
|
41
|
+
const providerAttributes = { ...safeAttributes };
|
|
42
|
+
delete providerAttributes[CODE_ATTRIBUTE];
|
|
43
|
+
provider.reportError(serialized, { module, code, attributes: providerAttributes });
|
|
44
|
+
} catch (sinkError) {
|
|
45
|
+
logger.warn("Telemetry provider error sink failed", { err: sinkError });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function errorLabels(module, code) {
|
|
49
|
+
if (!module && !code) return void 0;
|
|
50
|
+
const labels = {};
|
|
51
|
+
if (module) labels.module = module;
|
|
52
|
+
if (code) labels[CODE_ATTRIBUTE] = code;
|
|
53
|
+
return labels;
|
|
22
54
|
}
|
|
23
55
|
export {
|
|
24
56
|
reportError
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/facade/report-error.ts"],
|
|
4
|
-
"sourcesContent": ["import type { Attributes } from '../types'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { currentSpan } from './tracer'\nimport { serializeError } from './serialize'\nimport { counter } from './meter'\nimport { redactAttributes } from './redact'\n\nconst logger = createLogger('telemetry')\n\nexport type ReportErrorContext = {\n /** Owning module, e.g. 'orders'. Used as
|
|
5
|
-
"mappings": "AACA,SAAS,oBAAoB;AAC7B,SAAS,mBAAmB;AAC5B,SAAS,sBAAsB;AAC/B,SAAS,eAAe;AACxB,SAAS,wBAAwB;
|
|
4
|
+
"sourcesContent": ["import type { Attributes, LogRecord } from '../types'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { groupableCode } from '@open-mercato/shared/lib/telemetry/error-code'\nimport { currentSpan } from './tracer'\nimport { serializeError } from './serialize'\nimport { counter } from './meter'\nimport { redactAttributes } from './redact'\nimport { getActiveProvider } from '../provider/registry'\n\nconst logger = createLogger('telemetry')\n\n/**\n * The single attribute name the code is published under, on all three built-in\n * surfaces. One spelling, so a query written against the span also works against\n * the log record and the metric.\n */\nconst CODE_ATTRIBUTE = 'error.code'\n\nexport type ReportErrorContext = {\n /** Owning module, e.g. 'orders'. Used as a metric label. */\n module?: string\n /**\n * Stable, enumerated fingerprint for this failure reason, as `module.reason`\n * (`data_sync.item_failed`, `queue.job_failed`). NEVER an interpolated string:\n * it is a metric label, and it is what the backend groups on. A value that is\n * not that shape is dropped here rather than published \u2014 see `groupableCode`.\n *\n * Grouping is why this exists. Sentry fingerprints on the stack trace and New\n * Relic on the error class, so a funnel that synthesizes one error type at one\n * line \u2014 the integration-log tee \u2014 collapses every caller into a single issue\n * without a discriminator, and SigNoz has no error grouping of its own at all.\n */\n code?: string\n /** Low-cardinality, NO-PII attributes (ids ok, never names/content). */\n attributes?: Attributes\n}\n\n/**\n * Re-entrancy guard, not a rate limit. A plain module-level flag, which is\n * sufficient only because `reportError` runs synchronously end to end: a nested\n * call can therefore come from nothing but the reporting path itself \u2014 a provider\n * whose error sink reports its own failures, a log bridge that reports. One flag\n * turns that recursion into a no-op while keeping the original report intact. Add\n * an `await` anywhere in this funnel and the flag has to become async-context\n * scoped, or concurrent reports will suppress each other.\n *\n * Volume is deliberately NOT policed here: the backend (quotas, spike\n * protection), the collector (filter/sample processors) and the SDK's batch\n * processors already bound it, and each of them drops where the drop is visible\n * and adjustable. A fourth limiter in this function would be the only one that\n * loses an error irrecoverably at the source.\n */\nlet reporting = false\n\n/**\n * The error funnel. Additive \u2014 existing `console.error` calls stay valid; this\n * is the path that reaches the active backend. It:\n * 1. records the exception on the active span (errors-as-span-events),\n * 2. emits a structured error log (stack only \u2014 no PII payloads),\n * 3. increments the `om.errors` counter, labeled by `module` and `error.code`,\n * 4. hands the serialized error to the provider's own error sink when it has\n * one (issue-tracker-shaped backends), in ADDITION to the three above so no\n * path can lose signal.\n *\n * Every reported error is emitted; there is no sampling or suppression here.\n */\nexport function reportError(error: unknown, ctx?: ReportErrorContext): void {\n if (reporting) return\n reporting = true\n try {\n // Narrowed once, here, rather than at each caller: the property being\n // protected \u2014 a metric label is bounded and never redacted \u2014 belongs to this\n // funnel, and `code` reaches it from adapters and third-party modules. A\n // caller with a malformed code reports exactly as one with no code at all.\n const code = groupableCode(ctx?.code)\n const span = currentSpan()\n if (span) {\n span.recordException(error)\n span.setStatus('error')\n // Span-level, because `recordException` takes no attributes. With several\n // reports on one span the last code wins; the per-error code always\n // survives on the log record and the metric.\n if (code) span.setAttribute(CODE_ATTRIBUTE, code)\n }\n\n const serialized = serializeError(error)\n const attributes: Attributes = { ...(ctx?.attributes ?? {}) }\n if (ctx?.module) attributes.module = ctx.module\n if (code) attributes[CODE_ATTRIBUTE] = code\n const safeAttributes = redactAttributes(attributes)\n\n const safeError = new Error(serialized.message)\n safeError.name = serialized.name\n safeError.stack = serialized.stack\n logger.error('Application error reported', { ...safeAttributes, err: safeError })\n counter('om.errors', 1, errorLabels(ctx?.module, code))\n\n reportToProvider(serialized, ctx?.module, code, safeAttributes)\n } finally {\n reporting = false\n }\n}\n\n/**\n * Hand the error to the provider's own sink, when it has one.\n *\n * Guarded, and that is the whole point of the function: `reportError` is called\n * from `catch` blocks that still have work to do after it \u2014 the API dispatcher\n * records a duration metric, emits its lifecycle event and rethrows the original\n * error; the CRUD factory returns a 500 carrying its `x-request-id`. This hook is\n * implemented by third parties, so one that throws would take all of that with it\n * and replace the caller's error with the telemetry provider's. The interface\n * documents the hook as non-throwing; this is what makes that a property of the\n * funnel rather than a request to its implementors.\n */\nfunction reportToProvider(\n serialized: NonNullable<LogRecord['error']>,\n module: string | undefined,\n code: string | undefined,\n safeAttributes: Attributes,\n): void {\n try {\n const provider = getActiveProvider()\n if (!provider.reportError) return\n // `code` travels in the context. Repeating it in the attributes would land it\n // in a provider's tags AND its extra data for anyone following the README recipe.\n const providerAttributes: Attributes = { ...safeAttributes }\n delete providerAttributes[CODE_ATTRIBUTE]\n provider.reportError(serialized, { module, code, attributes: providerAttributes })\n } catch (sinkError) {\n logger.warn('Telemetry provider error sink failed', { err: sinkError as Error })\n }\n}\n\nfunction errorLabels(module?: string, code?: string): Attributes | undefined {\n if (!module && !code) return undefined\n const labels: Attributes = {}\n if (module) labels.module = module\n if (code) labels[CODE_ATTRIBUTE] = code\n return labels\n}\n"],
|
|
5
|
+
"mappings": "AACA,SAAS,oBAAoB;AAC7B,SAAS,qBAAqB;AAC9B,SAAS,mBAAmB;AAC5B,SAAS,sBAAsB;AAC/B,SAAS,eAAe;AACxB,SAAS,wBAAwB;AACjC,SAAS,yBAAyB;AAElC,MAAM,SAAS,aAAa,WAAW;AAOvC,MAAM,iBAAiB;AAoCvB,IAAI,YAAY;AAcT,SAAS,YAAY,OAAgB,KAAgC;AAC1E,MAAI,UAAW;AACf,cAAY;AACZ,MAAI;AAKF,UAAM,OAAO,cAAc,KAAK,IAAI;AACpC,UAAM,OAAO,YAAY;AACzB,QAAI,MAAM;AACR,WAAK,gBAAgB,KAAK;AAC1B,WAAK,UAAU,OAAO;AAItB,UAAI,KAAM,MAAK,aAAa,gBAAgB,IAAI;AAAA,IAClD;AAEA,UAAM,aAAa,eAAe,KAAK;AACvC,UAAM,aAAyB,EAAE,GAAI,KAAK,cAAc,CAAC,EAAG;AAC5D,QAAI,KAAK,OAAQ,YAAW,SAAS,IAAI;AACzC,QAAI,KAAM,YAAW,cAAc,IAAI;AACvC,UAAM,iBAAiB,iBAAiB,UAAU;AAElD,UAAM,YAAY,IAAI,MAAM,WAAW,OAAO;AAC9C,cAAU,OAAO,WAAW;AAC5B,cAAU,QAAQ,WAAW;AAC7B,WAAO,MAAM,8BAA8B,EAAE,GAAG,gBAAgB,KAAK,UAAU,CAAC;AAChF,YAAQ,aAAa,GAAG,YAAY,KAAK,QAAQ,IAAI,CAAC;AAEtD,qBAAiB,YAAY,KAAK,QAAQ,MAAM,cAAc;AAAA,EAChE,UAAE;AACA,gBAAY;AAAA,EACd;AACF;AAcA,SAAS,iBACP,YACA,QACA,MACA,gBACM;AACN,MAAI;AACF,UAAM,WAAW,kBAAkB;AACnC,QAAI,CAAC,SAAS,YAAa;AAG3B,UAAM,qBAAiC,EAAE,GAAG,eAAe;AAC3D,WAAO,mBAAmB,cAAc;AACxC,aAAS,YAAY,YAAY,EAAE,QAAQ,MAAM,YAAY,mBAAmB,CAAC;AAAA,EACnF,SAAS,WAAW;AAClB,WAAO,KAAK,wCAAwC,EAAE,KAAK,UAAmB,CAAC;AAAA,EACjF;AACF;AAEA,SAAS,YAAY,QAAiB,MAAuC;AAC3E,MAAI,CAAC,UAAU,CAAC,KAAM,QAAO;AAC7B,QAAM,SAAqB,CAAC;AAC5B,MAAI,OAAQ,QAAO,SAAS;AAC5B,MAAI,KAAM,QAAO,cAAc,IAAI;AACnC,SAAO;AACT;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/telemetry",
|
|
3
|
-
"version": "0.7.1-develop.
|
|
3
|
+
"version": "0.7.1-develop.7185.1.0f280ef1f1",
|
|
4
4
|
"description": "Vendor-neutral spans, metrics, and error reporting backed by a pluggable OTLP provider. Off by default; the telemetry runtime is loaded only when explicitly enabled.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
}
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@open-mercato/shared": "0.7.1-develop.
|
|
32
|
+
"@open-mercato/shared": "0.7.1-develop.7185.1.0f280ef1f1"
|
|
33
33
|
},
|
|
34
34
|
"optionalDependencies": {
|
|
35
35
|
"@opentelemetry/api": "^1.9.1",
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { reportError } from '../index'
|
|
2
|
+
import {
|
|
3
|
+
resetLoggerExtension,
|
|
4
|
+
resetLoggerRegistry,
|
|
5
|
+
} from '@open-mercato/shared/lib/logger'
|
|
6
|
+
import { resetTelemetryRuntime } from '@open-mercato/shared/lib/telemetry/runtime'
|
|
7
|
+
import { initTelemetry, resetTelemetryInit } from '../init'
|
|
8
|
+
import { registerProvider, resetActiveProvider } from '../provider/registry'
|
|
9
|
+
import { resetTelemetryEnvCache } from '../env'
|
|
10
|
+
import { runSpan } from '../provider/run-span'
|
|
11
|
+
import type { Attributes, LogRecord, MetricPoint, Span, SpanOptions, TelemetryProvider, TraceCarrier } from '../types'
|
|
12
|
+
|
|
13
|
+
type ReportedError = {
|
|
14
|
+
error: NonNullable<LogRecord['error']>
|
|
15
|
+
context: { module?: string; code?: string; attributes?: Attributes }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
class RecordingSpan implements Span {
|
|
19
|
+
attributes: Record<string, unknown> = {}
|
|
20
|
+
exceptions: unknown[] = []
|
|
21
|
+
status: 'ok' | 'error' = 'ok'
|
|
22
|
+
setAttribute(key: string, value: string | number | boolean): void {
|
|
23
|
+
this.attributes[key] = value
|
|
24
|
+
}
|
|
25
|
+
setAttributes(attributes: Record<string, string | number | boolean | undefined>): void {
|
|
26
|
+
Object.assign(this.attributes, attributes)
|
|
27
|
+
}
|
|
28
|
+
recordException(error: unknown): void {
|
|
29
|
+
this.exceptions.push(error)
|
|
30
|
+
}
|
|
31
|
+
setStatus(status: 'ok' | 'error'): void {
|
|
32
|
+
this.status = status
|
|
33
|
+
}
|
|
34
|
+
end(): void {}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* `withErrorSink` opts the provider into the optional `reportError` hook, so one
|
|
39
|
+
* harness covers both a provider that implements it and a provider that predates
|
|
40
|
+
* it (the third-party compatibility case the contract exists to protect).
|
|
41
|
+
*/
|
|
42
|
+
function recordingProvider(options?: { withErrorSink?: boolean; onReportError?: () => void }) {
|
|
43
|
+
const span = new RecordingSpan()
|
|
44
|
+
const logs: LogRecord[] = []
|
|
45
|
+
const metrics: MetricPoint[] = []
|
|
46
|
+
const reported: ReportedError[] = []
|
|
47
|
+
const provider: TelemetryProvider = {
|
|
48
|
+
name: 'console',
|
|
49
|
+
supports: ['traces', 'metrics', 'logs', 'errors'],
|
|
50
|
+
async start() {},
|
|
51
|
+
async shutdown() {},
|
|
52
|
+
runInSpan<T>(_name: string, _options: SpanOptions, fn: (s: Span) => T): T {
|
|
53
|
+
return runSpan(span, fn)
|
|
54
|
+
},
|
|
55
|
+
activeSpan: () => span,
|
|
56
|
+
activeTraceContext: () => undefined,
|
|
57
|
+
inject: () => {},
|
|
58
|
+
runInRemoteSpan<T>(_carrier: TraceCarrier, _name: string, _options: SpanOptions, fn: (s: Span) => T): T {
|
|
59
|
+
return runSpan(span, fn)
|
|
60
|
+
},
|
|
61
|
+
emitLog: (record) => logs.push(record),
|
|
62
|
+
recordMetric: (point) => metrics.push(point),
|
|
63
|
+
...(options?.withErrorSink
|
|
64
|
+
? {
|
|
65
|
+
reportError: (error: NonNullable<LogRecord['error']>, context: ReportedError['context']) => {
|
|
66
|
+
reported.push({ error, context })
|
|
67
|
+
options.onReportError?.()
|
|
68
|
+
},
|
|
69
|
+
}
|
|
70
|
+
: {}),
|
|
71
|
+
}
|
|
72
|
+
return { provider, span, logs, metrics, reported }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function activate(provider: TelemetryProvider): Promise<void> {
|
|
76
|
+
process.env.TELEMETRY_BACKEND = 'console'
|
|
77
|
+
registerProvider(provider)
|
|
78
|
+
await initTelemetry()
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function errorLogs(logs: LogRecord[]): LogRecord[] {
|
|
82
|
+
return logs.filter((record) => record.level === 'error')
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
describe('reportError policy', () => {
|
|
86
|
+
beforeEach(() => {
|
|
87
|
+
resetActiveProvider()
|
|
88
|
+
resetTelemetryInit()
|
|
89
|
+
resetTelemetryEnvCache()
|
|
90
|
+
resetLoggerExtension()
|
|
91
|
+
resetLoggerRegistry()
|
|
92
|
+
resetTelemetryRuntime()
|
|
93
|
+
delete process.env.TELEMETRY_BACKEND
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
afterEach(() => {
|
|
97
|
+
delete process.env.TELEMETRY_BACKEND
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('stamps the code on the span, the log record and the metric label', async () => {
|
|
101
|
+
const { provider, span, logs, metrics } = recordingProvider()
|
|
102
|
+
await activate(provider)
|
|
103
|
+
|
|
104
|
+
reportError(new Error('item rejected'), {
|
|
105
|
+
module: 'data_sync',
|
|
106
|
+
code: 'data_sync.item_failed',
|
|
107
|
+
attributes: { runId: 'run-1' },
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
expect(span.attributes['error.code']).toBe('data_sync.item_failed')
|
|
111
|
+
expect(span.status).toBe('error')
|
|
112
|
+
const record = errorLogs(logs)[0]
|
|
113
|
+
expect(record?.attributes?.['error.code']).toBe('data_sync.item_failed')
|
|
114
|
+
expect(record?.attributes?.runId).toBe('run-1')
|
|
115
|
+
expect(metrics.find((metric) => metric.name === 'om.errors')?.labels).toEqual({
|
|
116
|
+
module: 'data_sync',
|
|
117
|
+
'error.code': 'data_sync.item_failed',
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('leaves callers that pass no code exactly as they were', async () => {
|
|
122
|
+
const { provider, span, logs, metrics } = recordingProvider()
|
|
123
|
+
await activate(provider)
|
|
124
|
+
|
|
125
|
+
reportError(new Error('boom'), { module: 'crud' })
|
|
126
|
+
|
|
127
|
+
expect(metrics.find((metric) => metric.name === 'om.errors')?.labels).toEqual({ module: 'crud' })
|
|
128
|
+
expect(errorLogs(logs)[0]?.attributes?.['error.code']).toBeUndefined()
|
|
129
|
+
expect(span.attributes['error.code']).toBeUndefined()
|
|
130
|
+
expect(span.exceptions).toHaveLength(1)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('drops a code that is not a groupable fingerprint rather than publishing it', async () => {
|
|
134
|
+
const { provider, span, logs, metrics, reported } = recordingProvider({ withErrorSink: true })
|
|
135
|
+
await activate(provider)
|
|
136
|
+
|
|
137
|
+
// A metric label is unbounded cardinality and skips redaction, so the funnel
|
|
138
|
+
// narrows the code once here rather than trusting every caller to.
|
|
139
|
+
reportError(new Error('upstream rejected the item'), {
|
|
140
|
+
module: 'integrations',
|
|
141
|
+
code: 'http_404_https://erp.example.com/api/customers/jan.kowalski@example.com',
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
expect(metrics.find((metric) => metric.name === 'om.errors')?.labels).toEqual({ module: 'integrations' })
|
|
145
|
+
expect(errorLogs(logs)[0]?.attributes?.['error.code']).toBeUndefined()
|
|
146
|
+
expect(span.attributes['error.code']).toBeUndefined()
|
|
147
|
+
expect(reported[0]?.context.code).toBeUndefined()
|
|
148
|
+
// Dropping the fingerprint never drops the error itself.
|
|
149
|
+
expect(span.exceptions).toHaveLength(1)
|
|
150
|
+
expect(errorLogs(logs)).toHaveLength(1)
|
|
151
|
+
expect(reported).toHaveLength(1)
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('reports every occurrence — no sampling, no suppression', async () => {
|
|
155
|
+
const { provider, logs, metrics, reported } = recordingProvider({ withErrorSink: true })
|
|
156
|
+
await activate(provider)
|
|
157
|
+
|
|
158
|
+
for (let index = 0; index < 115; index += 1) {
|
|
159
|
+
reportError(new Error('same failure'), { module: 'data_sync', code: 'data_sync.item_failed' })
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
expect(errorLogs(logs)).toHaveLength(115)
|
|
163
|
+
expect(reported).toHaveLength(115)
|
|
164
|
+
expect(metrics.filter((metric) => metric.name === 'om.errors')).toHaveLength(115)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('hands the provider a serialized, redacted error in addition to span, log and metric', async () => {
|
|
168
|
+
const { provider, span, logs, metrics, reported } = recordingProvider({ withErrorSink: true })
|
|
169
|
+
await activate(provider)
|
|
170
|
+
|
|
171
|
+
reportError(new Error('no user for jan.kowalski@example.com'), {
|
|
172
|
+
module: 'integrations',
|
|
173
|
+
code: 'integrations.log_error',
|
|
174
|
+
attributes: { authorization: 'Bearer sk_live_abc', integrationId: 'sync_akeneo' },
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
expect(reported).toHaveLength(1)
|
|
178
|
+
expect(reported[0]?.error.name).toBe('Error')
|
|
179
|
+
expect(reported[0]?.error.message).toBe('no user for [redacted-email]')
|
|
180
|
+
expect(reported[0]?.context.code).toBe('integrations.log_error')
|
|
181
|
+
expect(reported[0]?.context.module).toBe('integrations')
|
|
182
|
+
expect(reported[0]?.context.attributes?.authorization).toBe('[redacted]')
|
|
183
|
+
expect(reported[0]?.context.attributes?.integrationId).toBe('sync_akeneo')
|
|
184
|
+
// Carried once, in the context — a provider author following the README recipe
|
|
185
|
+
// should not find it in `extra` as well as `tags`.
|
|
186
|
+
expect(reported[0]?.context.attributes?.['error.code']).toBeUndefined()
|
|
187
|
+
// The hook is additive: the built-in path still fires, so a provider that
|
|
188
|
+
// implements it cannot make signal disappear.
|
|
189
|
+
expect(span.exceptions).toHaveLength(1)
|
|
190
|
+
expect(errorLogs(logs)).toHaveLength(1)
|
|
191
|
+
expect(metrics.some((metric) => metric.name === 'om.errors')).toBe(true)
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('does not recurse when the reporting path itself reports', async () => {
|
|
195
|
+
let nested = 0
|
|
196
|
+
const { provider, logs, reported } = recordingProvider({
|
|
197
|
+
withErrorSink: true,
|
|
198
|
+
onReportError: () => {
|
|
199
|
+
nested += 1
|
|
200
|
+
if (nested > 5) return
|
|
201
|
+
reportError(new Error('failure inside the error sink'), { module: 'telemetry' })
|
|
202
|
+
},
|
|
203
|
+
})
|
|
204
|
+
await activate(provider)
|
|
205
|
+
|
|
206
|
+
reportError(new Error('original failure'), { module: 'data_sync', code: 'data_sync.item_failed' })
|
|
207
|
+
|
|
208
|
+
expect(reported).toHaveLength(1)
|
|
209
|
+
expect(reported[0]?.error.message).toBe('original failure')
|
|
210
|
+
expect(errorLogs(logs)).toHaveLength(1)
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('survives a provider whose error sink throws, without losing any built-in signal', async () => {
|
|
214
|
+
const { provider, span, logs, metrics } = recordingProvider({
|
|
215
|
+
withErrorSink: true,
|
|
216
|
+
onReportError: () => {
|
|
217
|
+
throw new Error('sentry transport is down')
|
|
218
|
+
},
|
|
219
|
+
})
|
|
220
|
+
await activate(provider)
|
|
221
|
+
|
|
222
|
+
// `reportError` is called from catch blocks that still have work to do after
|
|
223
|
+
// it — rethrowing the caller's error, returning a 500 with its correlation
|
|
224
|
+
// header. A third-party hook must never be able to take that with it.
|
|
225
|
+
expect(() =>
|
|
226
|
+
reportError(new Error('item rejected'), { module: 'data_sync', code: 'data_sync.item_failed' }),
|
|
227
|
+
).not.toThrow()
|
|
228
|
+
|
|
229
|
+
expect(span.exceptions).toHaveLength(1)
|
|
230
|
+
expect(span.attributes['error.code']).toBe('data_sync.item_failed')
|
|
231
|
+
expect(errorLogs(logs)).toHaveLength(1)
|
|
232
|
+
expect(metrics.filter((metric) => metric.name === 'om.errors')).toHaveLength(1)
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('is unaffected by a provider without the optional error sink', async () => {
|
|
236
|
+
const { provider, logs } = recordingProvider()
|
|
237
|
+
await activate(provider)
|
|
238
|
+
|
|
239
|
+
expect(() => reportError(new Error('boom'), { module: 'queue', code: 'queue.job_failed' })).not.toThrow()
|
|
240
|
+
expect(errorLogs(logs)).toHaveLength(1)
|
|
241
|
+
})
|
|
242
|
+
})
|
|
@@ -1,41 +1,141 @@
|
|
|
1
|
-
import type { Attributes } from '../types'
|
|
1
|
+
import type { Attributes, LogRecord } from '../types'
|
|
2
2
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
3
|
+
import { groupableCode } from '@open-mercato/shared/lib/telemetry/error-code'
|
|
3
4
|
import { currentSpan } from './tracer'
|
|
4
5
|
import { serializeError } from './serialize'
|
|
5
6
|
import { counter } from './meter'
|
|
6
7
|
import { redactAttributes } from './redact'
|
|
8
|
+
import { getActiveProvider } from '../provider/registry'
|
|
7
9
|
|
|
8
10
|
const logger = createLogger('telemetry')
|
|
9
11
|
|
|
12
|
+
/**
|
|
13
|
+
* The single attribute name the code is published under, on all three built-in
|
|
14
|
+
* surfaces. One spelling, so a query written against the span also works against
|
|
15
|
+
* the log record and the metric.
|
|
16
|
+
*/
|
|
17
|
+
const CODE_ATTRIBUTE = 'error.code'
|
|
18
|
+
|
|
10
19
|
export type ReportErrorContext = {
|
|
11
|
-
/** Owning module, e.g. 'orders'. Used as
|
|
20
|
+
/** Owning module, e.g. 'orders'. Used as a metric label. */
|
|
12
21
|
module?: string
|
|
22
|
+
/**
|
|
23
|
+
* Stable, enumerated fingerprint for this failure reason, as `module.reason`
|
|
24
|
+
* (`data_sync.item_failed`, `queue.job_failed`). NEVER an interpolated string:
|
|
25
|
+
* it is a metric label, and it is what the backend groups on. A value that is
|
|
26
|
+
* not that shape is dropped here rather than published — see `groupableCode`.
|
|
27
|
+
*
|
|
28
|
+
* Grouping is why this exists. Sentry fingerprints on the stack trace and New
|
|
29
|
+
* Relic on the error class, so a funnel that synthesizes one error type at one
|
|
30
|
+
* line — the integration-log tee — collapses every caller into a single issue
|
|
31
|
+
* without a discriminator, and SigNoz has no error grouping of its own at all.
|
|
32
|
+
*/
|
|
33
|
+
code?: string
|
|
13
34
|
/** Low-cardinality, NO-PII attributes (ids ok, never names/content). */
|
|
14
35
|
attributes?: Attributes
|
|
15
36
|
}
|
|
16
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Re-entrancy guard, not a rate limit. A plain module-level flag, which is
|
|
40
|
+
* sufficient only because `reportError` runs synchronously end to end: a nested
|
|
41
|
+
* call can therefore come from nothing but the reporting path itself — a provider
|
|
42
|
+
* whose error sink reports its own failures, a log bridge that reports. One flag
|
|
43
|
+
* turns that recursion into a no-op while keeping the original report intact. Add
|
|
44
|
+
* an `await` anywhere in this funnel and the flag has to become async-context
|
|
45
|
+
* scoped, or concurrent reports will suppress each other.
|
|
46
|
+
*
|
|
47
|
+
* Volume is deliberately NOT policed here: the backend (quotas, spike
|
|
48
|
+
* protection), the collector (filter/sample processors) and the SDK's batch
|
|
49
|
+
* processors already bound it, and each of them drops where the drop is visible
|
|
50
|
+
* and adjustable. A fourth limiter in this function would be the only one that
|
|
51
|
+
* loses an error irrecoverably at the source.
|
|
52
|
+
*/
|
|
53
|
+
let reporting = false
|
|
54
|
+
|
|
17
55
|
/**
|
|
18
56
|
* The error funnel. Additive — existing `console.error` calls stay valid; this
|
|
19
57
|
* is the path that reaches the active backend. It:
|
|
20
58
|
* 1. records the exception on the active span (errors-as-span-events),
|
|
21
59
|
* 2. emits a structured error log (stack only — no PII payloads),
|
|
22
|
-
* 3. increments the `om.errors` counter, labeled by `module`
|
|
60
|
+
* 3. increments the `om.errors` counter, labeled by `module` and `error.code`,
|
|
61
|
+
* 4. hands the serialized error to the provider's own error sink when it has
|
|
62
|
+
* one (issue-tracker-shaped backends), in ADDITION to the three above so no
|
|
63
|
+
* path can lose signal.
|
|
64
|
+
*
|
|
65
|
+
* Every reported error is emitted; there is no sampling or suppression here.
|
|
23
66
|
*/
|
|
24
67
|
export function reportError(error: unknown, ctx?: ReportErrorContext): void {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
68
|
+
if (reporting) return
|
|
69
|
+
reporting = true
|
|
70
|
+
try {
|
|
71
|
+
// Narrowed once, here, rather than at each caller: the property being
|
|
72
|
+
// protected — a metric label is bounded and never redacted — belongs to this
|
|
73
|
+
// funnel, and `code` reaches it from adapters and third-party modules. A
|
|
74
|
+
// caller with a malformed code reports exactly as one with no code at all.
|
|
75
|
+
const code = groupableCode(ctx?.code)
|
|
76
|
+
const span = currentSpan()
|
|
77
|
+
if (span) {
|
|
78
|
+
span.recordException(error)
|
|
79
|
+
span.setStatus('error')
|
|
80
|
+
// Span-level, because `recordException` takes no attributes. With several
|
|
81
|
+
// reports on one span the last code wins; the per-error code always
|
|
82
|
+
// survives on the log record and the metric.
|
|
83
|
+
if (code) span.setAttribute(CODE_ATTRIBUTE, code)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const serialized = serializeError(error)
|
|
87
|
+
const attributes: Attributes = { ...(ctx?.attributes ?? {}) }
|
|
88
|
+
if (ctx?.module) attributes.module = ctx.module
|
|
89
|
+
if (code) attributes[CODE_ATTRIBUTE] = code
|
|
90
|
+
const safeAttributes = redactAttributes(attributes)
|
|
91
|
+
|
|
92
|
+
const safeError = new Error(serialized.message)
|
|
93
|
+
safeError.name = serialized.name
|
|
94
|
+
safeError.stack = serialized.stack
|
|
95
|
+
logger.error('Application error reported', { ...safeAttributes, err: safeError })
|
|
96
|
+
counter('om.errors', 1, errorLabels(ctx?.module, code))
|
|
97
|
+
|
|
98
|
+
reportToProvider(serialized, ctx?.module, code, safeAttributes)
|
|
99
|
+
} finally {
|
|
100
|
+
reporting = false
|
|
29
101
|
}
|
|
102
|
+
}
|
|
30
103
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
104
|
+
/**
|
|
105
|
+
* Hand the error to the provider's own sink, when it has one.
|
|
106
|
+
*
|
|
107
|
+
* Guarded, and that is the whole point of the function: `reportError` is called
|
|
108
|
+
* from `catch` blocks that still have work to do after it — the API dispatcher
|
|
109
|
+
* records a duration metric, emits its lifecycle event and rethrows the original
|
|
110
|
+
* error; the CRUD factory returns a 500 carrying its `x-request-id`. This hook is
|
|
111
|
+
* implemented by third parties, so one that throws would take all of that with it
|
|
112
|
+
* and replace the caller's error with the telemetry provider's. The interface
|
|
113
|
+
* documents the hook as non-throwing; this is what makes that a property of the
|
|
114
|
+
* funnel rather than a request to its implementors.
|
|
115
|
+
*/
|
|
116
|
+
function reportToProvider(
|
|
117
|
+
serialized: NonNullable<LogRecord['error']>,
|
|
118
|
+
module: string | undefined,
|
|
119
|
+
code: string | undefined,
|
|
120
|
+
safeAttributes: Attributes,
|
|
121
|
+
): void {
|
|
122
|
+
try {
|
|
123
|
+
const provider = getActiveProvider()
|
|
124
|
+
if (!provider.reportError) return
|
|
125
|
+
// `code` travels in the context. Repeating it in the attributes would land it
|
|
126
|
+
// in a provider's tags AND its extra data for anyone following the README recipe.
|
|
127
|
+
const providerAttributes: Attributes = { ...safeAttributes }
|
|
128
|
+
delete providerAttributes[CODE_ATTRIBUTE]
|
|
129
|
+
provider.reportError(serialized, { module, code, attributes: providerAttributes })
|
|
130
|
+
} catch (sinkError) {
|
|
131
|
+
logger.warn('Telemetry provider error sink failed', { err: sinkError as Error })
|
|
132
|
+
}
|
|
133
|
+
}
|
|
35
134
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
135
|
+
function errorLabels(module?: string, code?: string): Attributes | undefined {
|
|
136
|
+
if (!module && !code) return undefined
|
|
137
|
+
const labels: Attributes = {}
|
|
138
|
+
if (module) labels.module = module
|
|
139
|
+
if (code) labels[CODE_ATTRIBUTE] = code
|
|
140
|
+
return labels
|
|
41
141
|
}
|
package/src/types.ts
CHANGED
|
@@ -124,6 +124,31 @@ export interface TelemetryProvider {
|
|
|
124
124
|
emitLog(record: LogRecord): void
|
|
125
125
|
/** Record a metric observation (no-op if `metrics` unsupported). */
|
|
126
126
|
recordMetric(point: MetricPoint): void
|
|
127
|
+
/**
|
|
128
|
+
* Receive a reported error as a first-class error, for backends that model
|
|
129
|
+
* errors as issues rather than as a span event plus a log record (Sentry and
|
|
130
|
+
* friends). The facade calls it in ADDITION to the span/log/metric path, never
|
|
131
|
+
* instead of it, so a provider that implements this cannot cause signal to be
|
|
132
|
+
* lost — it owns its own de-duplication.
|
|
133
|
+
*
|
|
134
|
+
* OPTIONAL, and MUST stay optional: third parties implement this interface, and
|
|
135
|
+
* a required method would break every existing provider. Facade callers invoke
|
|
136
|
+
* it as `provider.reportError?.(…)`.
|
|
137
|
+
*
|
|
138
|
+
* `error` arrives already serialized and PII-redacted (name/message/stack only)
|
|
139
|
+
* and `attributes` already redacted, so an implementation must not reach for
|
|
140
|
+
* the original thrown value.
|
|
141
|
+
*
|
|
142
|
+
* MUST NOT throw. The facade calls this from `catch` blocks that still have
|
|
143
|
+
* work to do — rethrowing the original error, returning a 500 with its
|
|
144
|
+
* correlation header — so it wraps this call and degrades a throwing hook to a
|
|
145
|
+
* warning. Do the same inside your implementation: a backend SDK that raises on
|
|
146
|
+
* a full buffer should be swallowed here, not escalated.
|
|
147
|
+
*/
|
|
148
|
+
reportError?(
|
|
149
|
+
error: NonNullable<LogRecord['error']>,
|
|
150
|
+
context: { module?: string; code?: string; attributes?: Attributes },
|
|
151
|
+
): void
|
|
127
152
|
}
|
|
128
153
|
|
|
129
154
|
/**
|