@open-mercato/telemetry 0.7.1-develop.7183.1.db9678eeb8 → 0.7.1-develop.7186.1.6e080a5017

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 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
- const span = currentSpan();
9
- if (span) {
10
- span.recordException(error);
11
- span.setStatus("error");
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
- const serialized = serializeError(error);
14
- const attributes = { ...ctx?.attributes ?? {} };
15
- if (ctx?.module) attributes.module = ctx.module;
16
- const safeAttributes = redactAttributes(attributes);
17
- const safeError = new Error(serialized.message);
18
- safeError.name = serialized.name;
19
- safeError.stack = serialized.stack;
20
- logger.error("Application error reported", { ...safeAttributes, err: safeError });
21
- counter("om.errors", 1, ctx?.module ? { module: ctx.module } : void 0);
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 the only metric label. */\n module?: string\n /** Low-cardinality, NO-PII attributes (ids ok, never names/content). */\n attributes?: Attributes\n}\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` only.\n */\nexport function reportError(error: unknown, ctx?: ReportErrorContext): void {\n const span = currentSpan()\n if (span) {\n span.recordException(error)\n span.setStatus('error')\n }\n\n const serialized = serializeError(error)\n const attributes: Attributes = { ...(ctx?.attributes ?? {}) }\n if (ctx?.module) attributes.module = ctx.module\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, ctx?.module ? { module: ctx.module } : undefined)\n}\n"],
5
- "mappings": "AACA,SAAS,oBAAoB;AAC7B,SAAS,mBAAmB;AAC5B,SAAS,sBAAsB;AAC/B,SAAS,eAAe;AACxB,SAAS,wBAAwB;AAEjC,MAAM,SAAS,aAAa,WAAW;AAgBhC,SAAS,YAAY,OAAgB,KAAgC;AAC1E,QAAM,OAAO,YAAY;AACzB,MAAI,MAAM;AACR,SAAK,gBAAgB,KAAK;AAC1B,SAAK,UAAU,OAAO;AAAA,EACxB;AAEA,QAAM,aAAa,eAAe,KAAK;AACvC,QAAM,aAAyB,EAAE,GAAI,KAAK,cAAc,CAAC,EAAG;AAC5D,MAAI,KAAK,OAAQ,YAAW,SAAS,IAAI;AACzC,QAAM,iBAAiB,iBAAiB,UAAU;AAElD,QAAM,YAAY,IAAI,MAAM,WAAW,OAAO;AAC9C,YAAU,OAAO,WAAW;AAC5B,YAAU,QAAQ,WAAW;AAC7B,SAAO,MAAM,8BAA8B,EAAE,GAAG,gBAAgB,KAAK,UAAU,CAAC;AAChF,UAAQ,aAAa,GAAG,KAAK,SAAS,EAAE,QAAQ,IAAI,OAAO,IAAI,MAAS;AAC1E;",
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.7183.1.db9678eeb8",
3
+ "version": "0.7.1-develop.7186.1.6e080a5017",
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.7183.1.db9678eeb8"
32
+ "@open-mercato/shared": "0.7.1-develop.7186.1.6e080a5017"
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 the only metric label. */
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` only.
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
- const span = currentSpan()
26
- if (span) {
27
- span.recordException(error)
28
- span.setStatus('error')
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
- const serialized = serializeError(error)
32
- const attributes: Attributes = { ...(ctx?.attributes ?? {}) }
33
- if (ctx?.module) attributes.module = ctx.module
34
- const safeAttributes = redactAttributes(attributes)
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
- const safeError = new Error(serialized.message)
37
- safeError.name = serialized.name
38
- safeError.stack = serialized.stack
39
- logger.error('Application error reported', { ...safeAttributes, err: safeError })
40
- counter('om.errors', 1, ctx?.module ? { module: ctx.module } : undefined)
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
  /**