@ddd-ts-kit/sentry 0.16.0 → 0.16.1

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.
Files changed (3) hide show
  1. package/README.fr.md +360 -0
  2. package/README.md +116 -2
  3. package/package.json +5 -5
package/README.fr.md ADDED
@@ -0,0 +1,360 @@
1
+ [English](README.md) · **Français**
2
+ <!-- translation-of: README.md sha256:281de46cd74226d4 -->
3
+
4
+ # @ddd-ts-kit/sentry
5
+
6
+ L'`ErrorReporter` du noyau sur Sentry ou sur GlitchTip, qui parle le même protocole : un seul
7
+ adaptateur, seul le DSN diffère. Les échecs inattendus seulement, chacun menant à sa trace,
8
+ nettoyés avant de quitter le processus. Rien dans `domain/` ni dans `application/` n'importe ce
9
+ *package*, ni ne remonte quoi que ce soit : les gestionnaires (handlers) lèvent une exception, et
10
+ ce qui les entoure la remonte.
11
+
12
+ ```
13
+ npm i @ddd-ts-kit/sentry @sentry/node @opentelemetry/api
14
+ ```
15
+
16
+ Écrit pour `@sentry/node` 11 : les options sur lesquelles il s'appuie
17
+ (`enableOpenTelemetrySetup`, `dataCollection`) n'existent pas dans les versions majeures
18
+ antérieures.
19
+
20
+ ![La place du rapporteur Sentry : les gestionnaires et les agrégats lèvent des exceptions et ne remontent rien ; cinq points de capture autour d'eux, les bus de commandes et de requêtes, le pipeline HTTP, le planificateur de jobs, DeadLetterReports sur les crochets des fournisseurs d'événements et reportProcessFailures sur le processus, confient un échec inattendu à l'ErrorReporter du noyau, une fois ; la racine de composition y lie le SentryErrorReporter d'initSentry ; l'événement est nettoyé, puis passé au beforeSend du projet, puis envoyé à Sentry ou GlitchTip ; son identifiant de trace mène à la trace dans le backend de traçage](https://raw.githubusercontent.com/gbourgeat/ddd-ts-kit/main/docs/assets/diagrams/sentry-capture-points.fr.svg)
21
+
22
+ Le code d'un cas d'usage ne voit jamais le rapporteur. Il lève une exception ; ce qui l'entoure
23
+ décide si l'échec est un incident, le décrit avec les identifiants du travail en cours et le
24
+ confie à l'`ErrorReporter` du noyau, le seul port que ce *package* implémente.
25
+
26
+ ## Racine de composition
27
+
28
+ ```ts
29
+ // platform/errors.ts : importé en second, juste après platform/tracing.ts
30
+ import { reportProcessFailures } from '@ddd-ts-kit/kernel';
31
+ import { initSentry } from '@ddd-ts-kit/sentry';
32
+ import { loadConfig } from '@/platform/config/load-config';
33
+
34
+ // { dsn?, environment, release?, scrubMessages } de la configuration validée.
35
+ // Sans DSN, rien ne démarre et rien n'est remonté.
36
+ export const errors = initSentry(loadConfig(process.env).errorReporting);
37
+
38
+ reportProcessFailures(errors); // uncaughtException et unhandledRejection : remontées, puis sortie 1
39
+ ```
40
+
41
+ ```ts
42
+ // main.ts
43
+ import { tracing } from '@/platform/tracing'; // en premier
44
+ import { errors } from '@/platform/errors'; // en second
45
+ import { DeadLetterReports, ExecutionScope } from '@ddd-ts-kit/kernel';
46
+ import { createApplication, kernelModule } from '@ddd-ts-kit/di';
47
+ import { reportHttpErrors } from '@ddd-ts-kit/http';
48
+ import { RabbitMqEventBus } from '@ddd-ts-kit/rabbitmq';
49
+
50
+ const deadLetters = new DeadLetterReports(errors);
51
+ const bus = new RabbitMqEventBus({ url, onFailure: (failure) => deadLetters.onFailure(failure) });
52
+
53
+ const app = createApplication({
54
+ modules: [
55
+ kernelModule({
56
+ errorReporter: errors, // les bus et les jobs remontent par lui, app.stop() le vide
57
+ eventBus: bus,
58
+ eventSubscribers: { forContext: (name) => deadLetters.observing(bus.forConsumer(name)) },
59
+ }),
60
+ ...contexts,
61
+ ],
62
+ });
63
+
64
+ const http = { onError: reportHttpErrors(errors), executionScope: app.resolve(ExecutionScope) };
65
+
66
+ process.on('SIGTERM', () => void app.stop().then(() => tracing.shutdown()));
67
+ ```
68
+
69
+ Trois lignes portent l'ensemble : `initSentry`, `errorReporter: errors`,
70
+ `onError: reportHttpErrors(errors)`. Les gestionnaires du processus et les files de rejet (dead
71
+ letters) tiennent chacun en une ligne, pour qui les veut.
72
+
73
+ `init --errors sentry` écrit exactement ces pièces, chacune dans son fichier :
74
+ `platform/errors.ts` comme ci-dessus, avec `SENTRY_DSN`, `SENTRY_ENVIRONMENT` (par défaut :
75
+ `NODE_ENV`), `SENTRY_RELEASE` et `SENTRY_SCRUB_MESSAGES` lus par
76
+ `platform/config/load-config.ts` ; `errors` passé par `main.ts` à `composeApplication`, qui le
77
+ donne à `kernelModule({ errorReporter })` ; `reportHttpErrors` dans
78
+ `platform/auth/http-boundary.ts` ; `DeadLetterReports` dans
79
+ `platform/events/create-event-provider.ts` (le *broker*) et dans
80
+ `platform/events/start-outbox-relay.ts` (une publication déclarée morte).
81
+
82
+ **Ordre de chargement.** `platform/tracing.ts` reste le premier import : OpenTelemetry modifie
83
+ `http` et les pilotes lorsqu'ils sont chargés, il doit donc s'exécuter avant eux. `initSentry` ne
84
+ modifie rien, sa place ne change donc rien pour le traçage, quel que soit celui des deux qui
85
+ démarre en premier (les deux ordres sont testés). Il vient en second pour qu'un échec pendant le
86
+ chargement du reste de l'application soit déjà remonté par `reportProcessFailures`.
87
+
88
+ ## Ce qui est remonté
89
+
90
+ Seulement ce que personne n'a prévu. Une `DomainError` est un refus métier, une réponse et non un
91
+ incident.
92
+
93
+ | Point de capture | Branché par | Remonte | Ne remonte pas |
94
+ |---|---|---|---|
95
+ | bus de commandes et de requêtes | `kernelModule` (les bus par défaut portent `errorReportingMiddleware`) | une commande ou une requête qui échoue avec autre chose qu'une `DomainError` | une `DomainError` ; quoi que ce soit pendant la consommation d'un événement |
96
+ | HTTP | `onError: reportHttpErrors(errors)` | toute requête HTTP qui reçoit une réponse 5xx, une `DomainError` traduite en 5xx comprise | une 4xx |
97
+ | *jobs* planifiés | `kernelModule` (le planificateur par défaut observe avec `reportFailedRuns`) | une exécution dont l'issue est `failed`, dépassements de délai compris | une `DomainError`, une occurrence sautée ou manquée |
98
+ | événements d'intégration | `DeadLetterReports` sur le crochet `onFailure` du fournisseur | un événement abandonné : nouvelles tentatives épuisées, message empoisonné, publication du relais de l'outbox déclarée morte | une livraison qui échoue et fait l'objet d'une nouvelle tentative |
99
+ | processus | `reportProcessFailures(errors)` | `uncaughtException` et `unhandledRejection`, en `fatal` | |
100
+
101
+ **Un échec, un rapport.** Le premier point de capture qui voit un objet d'erreur le remonte et le
102
+ marque ; les frontières qu'il traverse ensuite le reconnaissent, y compris comme `cause` d'une
103
+ erreur qui l'enveloppe. Une commande qui échoue au sein d'une requête HTTP est donc remontée une
104
+ fois, par le bus, sous le nom de la commande, et la route qui répond 500 reste silencieuse. Le
105
+ rapport dit tout de même d'où il vient : `origin` est l'origine du contexte d'exécution (`http`,
106
+ `schedule`), et l'identifiant de corrélation et la trace mènent à la requête HTTP.
107
+
108
+ ![Un échec, un rapport : une requête HTTP atteint la route, qui envoie une commande sur le bus ; le gestionnaire lève une erreur inattendue ; errorReportingMiddleware du bus la remonte sous le nom de la commande et la marque, puis la laisse poursuivre ; la route répond 500 et reportHttpErrors reconnaît l'erreur marquée et reste silencieux ; l'outil de suivi reçoit un seul événement, avec l'origine http, l'identifiant de corrélation et l'identifiant de trace](https://raw.githubusercontent.com/gbourgeat/ddd-ts-kit/main/docs/assets/diagrams/sentry-one-failure-one-report.fr.svg)
109
+
110
+ **Événements.** Un consommateur fait l'objet de nouvelles tentatives : ses premiers échecs sont
111
+ attendus, et les remonter enterrerait les vrais incidents. Le *middleware* du bus ne dit donc
112
+ rien pendant la consommation d'un événement, et `DeadLetterReports` remonte l'événement une fois
113
+ que son fournisseur l'abandonne. Les crochets des fournisseurs ne disent que des codes, aussi
114
+ `observing(subscriber)` retient ce que chaque gestionnaire a levé : le rapport porte la vraie
115
+ erreur et sa pile. Sans lui, ou pour un message empoisonné, le rapport est une `DeadLetterError`
116
+ qui nomme le code. Un événement enterré par une `DomainError` est remonté en `warning` : il est
117
+ perdu pour son consommateur tout de même.
118
+
119
+ ![Un événement dont le gestionnaire échoue sans cesse : à chaque tentative, observing retient ce que le gestionnaire a levé et le fournisseur appelle onFailure ; tant que le fournisseur réessaie, onFailure ne remonte rien ; à la dernière tentative, le fournisseur abandonne l'événement, onFailure apprend dead et remonte une fois, avec l'erreur que le gestionnaire a réellement levée, l'origine event et l'opération nommée d'après le type de l'événement et le consommateur](https://raw.githubusercontent.com/gbourgeat/ddd-ts-kit/main/docs/assets/diagrams/sentry-event-given-up.fr.svg)
120
+
121
+ Les mêmes étapes, jouées à la main contre les doubles du noyau, comme le ferait un test du
122
+ câblage :
123
+
124
+ <!-- checked sample -->
125
+ ```ts
126
+ import {
127
+ DeadLetterReports,
128
+ InMemoryErrorReporter,
129
+ type IntegrationEvent,
130
+ type IntegrationEventCallback,
131
+ type IntegrationEventSubscriber,
132
+ } from '@ddd-ts-kit/kernel';
133
+
134
+ // Un fournisseur réduit à ce qui compte ici : il garde le gestionnaire qu'on lui donne.
135
+ const handlers = new Map<string, IntegrationEventCallback>();
136
+ const provider: IntegrationEventSubscriber = {
137
+ subscribe: (type, handler) => {
138
+ handlers.set(type, handler);
139
+
140
+ return { unsubscribe: () => handlers.delete(type) };
141
+ },
142
+ };
143
+
144
+ const reporter = new InMemoryErrorReporter();
145
+ const deadLetters = new DeadLetterReports(reporter);
146
+
147
+ deadLetters.observing(provider).subscribe('ordering.order.placed', async () => {
148
+ throw new Error('ledger unavailable');
149
+ });
150
+
151
+ const event: IntegrationEvent = {
152
+ id: 'evt-1',
153
+ type: 'ordering.order.placed',
154
+ version: 1,
155
+ occurredAt: '2026-03-01T10:00:00.000Z',
156
+ payload: {},
157
+ };
158
+ const deliver = handlers.get('ordering.order.placed');
159
+
160
+ // Ce que le fournisseur dit de chaque livraison échouée : des codes et des identifiants seulement.
161
+ const failure = { kind: 'handler', code: 'Error', eventId: 'evt-1', eventType: 'ordering.order.placed', consumer: 'billing' };
162
+
163
+ await deliver?.(event).catch(() => undefined); // la tentative 1 échoue : observing retient l'erreur
164
+ deadLetters.onFailure({ ...failure, attempt: 1 }); // nouvelle tentative : aucun rapport
165
+
166
+ await deliver?.(event).catch(() => undefined); // la tentative 2, la dernière, échoue
167
+ deadLetters.onFailure({ ...failure, attempt: 2, dead: true }); // abandonné : un rapport
168
+
169
+ // Un seul rapport : l'Error('ledger unavailable') elle-même, origine 'event', opération 'ordering.order.placed billing'.
170
+ export const reports = reporter.reports;
171
+ ```
172
+
173
+ **Dans un gestionnaire.** Rien à écrire : un gestionnaire lève une exception, et le point de
174
+ capture qui l'entoure la remonte.
175
+
176
+ <!-- checked sample -->
177
+ ```ts
178
+ import { ConceptError, type ErrorReporter } from '@ddd-ts-kit/kernel';
179
+
180
+ interface PaymentGateway {
181
+ capture(paymentId: string): Promise<'captured' | 'declined'>;
182
+ }
183
+
184
+ interface CapturePaymentCommand {
185
+ readonly paymentId: string;
186
+ }
187
+
188
+ export class PaymentError extends ConceptError {
189
+ static declined(paymentId: string) {
190
+ return PaymentError.refusal('PAYMENT_DECLINED', `Payment ${paymentId} was declined.`, { paymentId });
191
+ }
192
+ }
193
+
194
+ // Au lieu de : un gestionnaire qui remonte lui-même, et cache l'échec à son appelant.
195
+ export class CapturePaymentReportingItself {
196
+ constructor(
197
+ private readonly gateway: PaymentGateway,
198
+ private readonly errors: ErrorReporter,
199
+ ) {}
200
+
201
+ async execute(command: CapturePaymentCommand): Promise<void> {
202
+ try {
203
+ await this.gateway.capture(command.paymentId);
204
+ } catch (error) {
205
+ this.errors.report(error, { origin: 'command', operation: 'CapturePayment', severity: 'error' });
206
+ }
207
+ }
208
+ }
209
+
210
+ // Écrivez : un gestionnaire qui lève une exception. Un refus est une DomainError, à laquelle on répond et qui n'est jamais remontée ;
211
+ // tout le reste se propage jusqu'au bus, qui le remonte une fois, avec l'acteur, le tenant et la trace.
212
+ export class CapturePaymentHandler {
213
+ constructor(private readonly gateway: PaymentGateway) {}
214
+
215
+ async execute(command: CapturePaymentCommand): Promise<void> {
216
+ if ((await this.gateway.capture(command.paymentId)) === 'declined') {
217
+ throw PaymentError.declined(command.paymentId);
218
+ }
219
+ }
220
+ }
221
+ ```
222
+
223
+ ## Avec OpenTelemetry
224
+
225
+ `@ddd-ts-kit/tracing` possède le fournisseur de traceurs, le gestionnaire de contexte, le
226
+ propagateur et les auto-instrumentations. `initSentry` n'en touche aucun :
227
+
228
+ - `enableOpenTelemetrySetup: false` : le SDK n'enregistre ni fournisseur de traceurs ni
229
+ propagateur ;
230
+ - aucune des intégrations par défaut du SDK, et `enableRuntimeChannelInjection: false` : aucune
231
+ seconde instrumentation de `http` ou d'un pilote ;
232
+ - pas de `tracesSampleRate` sauf s'il est donné : aucune donnée de performance ne part vers
233
+ l'outil de suivi.
234
+
235
+ Ce qui relie les deux est une seule lecture : quand un échec est remonté, le rapporteur prend
236
+ l'identifiant de trace et l'identifiant de *span* du *span* OpenTelemetry actif et les met dans
237
+ `contexts.trace` de l'événement. Depuis une *issue* dans Sentry ou GlitchTip, l'identifiant de
238
+ trace ouvre la trace dans le *backend* de traçage. Les *spans* eux-mêmes sont exportés une fois,
239
+ par l'exportateur du kit.
240
+
241
+ ## Confidentialité
242
+
243
+ | | Par défaut | Sur demande |
244
+ |---|---|---|
245
+ | requête HTTP | jamais collectée. Si autre chose en ajoute une : méthode, URL sans chaîne de requête, en-têtes qui ne portent ni secret d'authentification ni adresse. Aucun corps, aucun cookie | un `beforeSend` du projet |
246
+ | utilisateur | l'identifiant de l'acteur, rien d'autre : ni courriel, ni nom, ni adresse | |
247
+ | *tenant* | non envoyé | `tagTenant: true` |
248
+ | variables locales | jamais | |
249
+ | fil d'Ariane (breadcrumbs) | aucun n'est enregistré automatiquement : ni console, ni appel sortant | `Sentry.addBreadcrumb` là où le projet le décide |
250
+ | message de l'exception | envoyé | `scrubMessages: true` le retire pour chaque erreur qui n'est pas une `DomainError` |
251
+
252
+ **Messages.** Le message est la première chose qu'on lit dans un outil de suivi des erreurs, et
253
+ le premier endroit où fuient des données personnelles : un pilote cite la ligne qu'il a refusée.
254
+ Par défaut, il est gardé, parce qu'une *issue* intitulée `[message removed]` coûte à chaque
255
+ investigation, et parce que l'outil de suivi est un outil interne dont le projet contrôle l'accès
256
+ et la durée de conservation. Avec `scrubMessages: true`, seuls survivent les messages que le
257
+ projet a écrits lui-même, ceux de ses `DomainError` ; type, code, pile et trace restent pour les
258
+ autres. Activez-le quand l'outil de suivi est hébergé par un tiers ou quand les données sont
259
+ assez sensibles pour qu'aucun opérateur ne doive les y lire. Les *loggers* du kit
260
+ (`StdoutLogger`, `@ddd-ts-kit/pino`, `@ddd-ts-kit/winston`) prennent l'option sous le même nom,
261
+ avec la même règle et le même marqueur, `SCRUBBED_MESSAGE` du noyau.
262
+
263
+ `beforeSend` s'exécute en dernier, sur l'événement nettoyé, et peut l'écarter en renvoyant
264
+ `null`.
265
+
266
+ Le DSN reste à l'intérieur du client du SDK. Un `SentryErrorReporter` journalisé, décomposé ou
267
+ sérialisé affiche `SentryErrorReporter [client concealed]` ; le client du SDK, si vous le
268
+ journalisez vous-même, affiche ses options.
269
+
270
+ ## Regroupement et étiquettes
271
+
272
+ L'empreinte (fingerprint) ne contient jamais le message, si bien que des identifiants dans un
273
+ message ne transforment pas un problème en milliers d'*issues* : une `DomainError` est sa classe
274
+ et son code ; toute autre erreur est sa classe, son code quand elle en a un, et la fonction et le
275
+ fichier de son premier cadre de pile, sans numéros de ligne.
276
+
277
+ Étiquettes : `origin`, `operation`, `correlation_id`, `bounded_context`, `error_code`, et
278
+ `tenant_id` sur demande. Les champs qu'un projet ajoute à `ErrorReportExtensions` sont envoyés
279
+ comme contexte `report`.
280
+
281
+ ## Options
282
+
283
+ | Option | Par défaut | |
284
+ |---|---|---|
285
+ | `dsn` | aucun | sans lui, `initSentry` renvoie un `NoopErrorReporter` et ne démarre rien. `SENTRY_DSN` n'est pas lu en dehors de la configuration |
286
+ | `environment`, `release` | aucun | |
287
+ | `enabled` | `true` | `false` ne remonte rien, quel que soit le DSN |
288
+ | `sampleRate` | 1 | part des événements d'erreur envoyés |
289
+ | `tracesSampleRate` | aucun | seulement pour les *spans* démarrés par l'API de Sentry ; les *spans* du kit n'y vont jamais |
290
+ | `scrubMessages` | `false` | voir « Confidentialité » |
291
+ | `tagTenant` | `false` | |
292
+ | `beforeSend` | aucun | s'exécute après le nettoyage du kit |
293
+ | `integrations` | aucune | ajoutées aux erreurs liées et aux lignes de contexte du code source |
294
+
295
+ `flush(timeoutMs)` renvoie `true` quand il ne reste rien à envoyer. `app.stop()` l'appelle avec
296
+ deux secondes ; `reportProcessFailures` le fait avant de sortir.
297
+
298
+ ## GlitchTip pour le développement
299
+
300
+ GlitchTip 6 tourne comme un seul service à côté de son PostgreSQL (14 ou ultérieur) et, comme le
301
+ recommande sa documentation, de Valkey. L'image de développement crée un utilisateur, une
302
+ organisation et un projet, et affiche le DSN :
303
+
304
+ ```yaml
305
+ services:
306
+ glitchtip:
307
+ image: glitchtip/glitchtip:6.2.6
308
+ profiles: [errors]
309
+ depends_on:
310
+ glitchtip-postgres: { condition: service_healthy }
311
+ glitchtip-valkey: { condition: service_started }
312
+ ports: ['127.0.0.1:8000:8000']
313
+ environment:
314
+ SERVER_ROLE: all_in_one # web et worker dans un seul processus
315
+ DATABASE_URL: postgres://postgres:postgres@glitchtip-postgres:5432/glitchtip
316
+ VALKEY_URL: redis://glitchtip-valkey:6379
317
+ SECRET_KEY: local-development-only-not-a-secret # jamais en production : openssl rand -hex 32
318
+ GLITCHTIP_DOMAIN: http://localhost:8000
319
+ EMAIL_URL: consolemail://
320
+ DEFAULT_FROM_EMAIL: glitchtip@localhost
321
+ DEBUG: 'true' # développement seulement : exigé par l'amorçage ci-dessous
322
+ GLITCHTIP_BOOTSTRAP_DEV: 'true' # utilisateur test@example.com / admin_pass, organisation, projet, DSN
323
+ healthcheck:
324
+ test: [CMD, python, healthcheck.py]
325
+ interval: 10s
326
+ timeout: 6s
327
+ retries: 6
328
+ start_period: 180s # le premier démarrage migre la base de données
329
+ glitchtip-postgres:
330
+ image: postgres:18-alpine
331
+ profiles: [errors]
332
+ environment: { POSTGRES_DB: glitchtip, POSTGRES_PASSWORD: postgres }
333
+ volumes: ['glitchtip-postgres:/var/lib/postgresql']
334
+ healthcheck:
335
+ test: [CMD-SHELL, pg_isready -U postgres -d glitchtip]
336
+ interval: 5s
337
+ retries: 12
338
+ glitchtip-valkey:
339
+ image: valkey/valkey:9-alpine
340
+ profiles: [errors]
341
+
342
+ volumes:
343
+ glitchtip-postgres:
344
+ ```
345
+
346
+ ```
347
+ docker compose --profile errors up -d --wait
348
+ docker compose logs glitchtip | grep 'DSN:'
349
+ ```
350
+
351
+ Mettez le DSN dans `SENTRY_DSN` ; depuis un autre conteneur de l'ensemble Compose, remplacez
352
+ `localhost` par `glitchtip`. Connectez-vous sur http://localhost:8000 avec l'utilisateur
353
+ ci-dessus.
354
+
355
+ ## Dans un test
356
+
357
+ Rien de ce *package* : l'application de test lie `InMemoryErrorReporter`, et
358
+ `app.errors.reports` liste ce que les bus et les *jobs* ont remonté, sous forme de données :
359
+ chaque entrée est `{ error, report }`, l'erreur telle qu'elle a été levée et l'`ErrorReport` qui
360
+ la décrit.
package/README.md CHANGED
@@ -1,3 +1,5 @@
1
+ **English** · [Français](README.fr.md)
2
+
1
3
  # @ddd-ts-kit/sentry
2
4
 
3
5
  The kernel `ErrorReporter` on Sentry or on GlitchTip, which speaks the same protocol: one adapter,
@@ -12,6 +14,12 @@ npm i @ddd-ts-kit/sentry @sentry/node @opentelemetry/api
12
14
  Written against `@sentry/node` 11: the options it relies on (`enableOpenTelemetrySetup`,
13
15
  `dataCollection`) do not exist in earlier majors.
14
16
 
17
+ ![Where the Sentry reporter sits: handlers and aggregates throw and report nothing; five capture points around them, the command and query buses, the HTTP pipeline, the job scheduler, DeadLetterReports on the hooks of the event providers and reportProcessFailures on the process, hand an unexpected failure to the kernel ErrorReporter, once; the composition root binds the SentryErrorReporter of initSentry to it; the event is scrubbed, then passed to the beforeSend of the project, then sent to Sentry or GlitchTip; its trace id leads to the trace in the tracing backend](https://raw.githubusercontent.com/gbourgeat/ddd-ts-kit/main/docs/assets/diagrams/sentry-capture-points.svg)
18
+
19
+ The code of a use case never sees the reporter. It throws; what stands around it decides whether
20
+ the failure is an incident, describes it with the identifiers of the work in progress and hands
21
+ it to the kernel `ErrorReporter`, the one port this package implements.
22
+
15
23
  ## Composition root
16
24
 
17
25
  ```ts
@@ -31,9 +39,10 @@ reportProcessFailures(errors); // uncaughtException and unhandledRejection: repo
31
39
  // main.ts
32
40
  import { tracing } from '@/platform/tracing'; // first
33
41
  import { errors } from '@/platform/errors'; // second
34
- import { DeadLetterReports } from '@ddd-ts-kit/kernel';
42
+ import { DeadLetterReports, ExecutionScope } from '@ddd-ts-kit/kernel';
35
43
  import { createApplication, kernelModule } from '@ddd-ts-kit/di';
36
44
  import { reportHttpErrors } from '@ddd-ts-kit/http';
45
+ import { RabbitMqEventBus } from '@ddd-ts-kit/rabbitmq';
37
46
 
38
47
  const deadLetters = new DeadLetterReports(errors);
39
48
  const bus = new RabbitMqEventBus({ url, onFailure: (failure) => deadLetters.onFailure(failure) });
@@ -90,6 +99,8 @@ under the name of the command, and the route that answers 500 stays silent. The
90
99
  says where it came from: `origin` is the origin of the execution context (`http`, `schedule`),
91
100
  and the correlation id and the trace lead to the request.
92
101
 
102
+ ![One failure, one report: a request reaches the route, which sends a command on the bus; the handler throws an unexpected error; errorReportingMiddleware of the bus reports it under the name of the command and marks it, then lets it go on; the route answers 500 and reportHttpErrors recognises the marked error and stays silent; the tracker receives one event, with origin http, the correlation id and the trace id](https://raw.githubusercontent.com/gbourgeat/ddd-ts-kit/main/docs/assets/diagrams/sentry-one-failure-one-report.svg)
103
+
93
104
  **Events.** A consumer is retried: its first failures are expected, and reporting them would
94
105
  bury the real incidents. The bus middleware therefore says nothing while an event is consumed,
95
106
  and `DeadLetterReports` reports the event once its provider gives it up. The hooks of the
@@ -98,6 +109,108 @@ report carries the real error and its stack. Without it, or for a poison message
98
109
  is a `DeadLetterError` naming the code. An event buried by a `DomainError` is reported as a
99
110
  `warning`: it is lost to its consumer all the same.
100
111
 
112
+ ![An event whose handler keeps failing: at each attempt observing remembers what the handler threw and the provider calls onFailure; while the provider retries, onFailure reports nothing; at the last attempt the provider gives the event up, onFailure hears dead and reports once, with the error the handler really threw, origin event and the operation named after the event type and the consumer](https://raw.githubusercontent.com/gbourgeat/ddd-ts-kit/main/docs/assets/diagrams/sentry-event-given-up.svg)
113
+
114
+ The same steps, played by hand against the doubles of the kernel, as a test of the wiring would:
115
+
116
+ <!-- checked sample -->
117
+ ```ts
118
+ import {
119
+ DeadLetterReports,
120
+ InMemoryErrorReporter,
121
+ type IntegrationEvent,
122
+ type IntegrationEventCallback,
123
+ type IntegrationEventSubscriber,
124
+ } from '@ddd-ts-kit/kernel';
125
+
126
+ // A provider reduced to what matters here: it keeps the handler it is given.
127
+ const handlers = new Map<string, IntegrationEventCallback>();
128
+ const provider: IntegrationEventSubscriber = {
129
+ subscribe: (type, handler) => {
130
+ handlers.set(type, handler);
131
+
132
+ return { unsubscribe: () => handlers.delete(type) };
133
+ },
134
+ };
135
+
136
+ const reporter = new InMemoryErrorReporter();
137
+ const deadLetters = new DeadLetterReports(reporter);
138
+
139
+ deadLetters.observing(provider).subscribe('ordering.order.placed', async () => {
140
+ throw new Error('ledger unavailable');
141
+ });
142
+
143
+ const event: IntegrationEvent = {
144
+ id: 'evt-1',
145
+ type: 'ordering.order.placed',
146
+ version: 1,
147
+ occurredAt: '2026-03-01T10:00:00.000Z',
148
+ payload: {},
149
+ };
150
+ const deliver = handlers.get('ordering.order.placed');
151
+
152
+ // What the provider says of each failed delivery: codes and identifiers only.
153
+ const failure = { kind: 'handler', code: 'Error', eventId: 'evt-1', eventType: 'ordering.order.placed', consumer: 'billing' };
154
+
155
+ await deliver?.(event).catch(() => undefined); // attempt 1 fails: observing remembers the error
156
+ deadLetters.onFailure({ ...failure, attempt: 1 }); // retried: no report
157
+
158
+ await deliver?.(event).catch(() => undefined); // attempt 2, the last one, fails
159
+ deadLetters.onFailure({ ...failure, attempt: 2, dead: true }); // given up: one report
160
+
161
+ // One report: the Error('ledger unavailable') itself, origin 'event', operation 'ordering.order.placed billing'.
162
+ export const reports = reporter.reports;
163
+ ```
164
+
165
+ **In a handler.** Nothing to write: a handler throws, and the capture point around it reports.
166
+
167
+ <!-- checked sample -->
168
+ ```ts
169
+ import { ConceptError, type ErrorReporter } from '@ddd-ts-kit/kernel';
170
+
171
+ interface PaymentGateway {
172
+ capture(paymentId: string): Promise<'captured' | 'declined'>;
173
+ }
174
+
175
+ interface CapturePaymentCommand {
176
+ readonly paymentId: string;
177
+ }
178
+
179
+ export class PaymentError extends ConceptError {
180
+ static declined(paymentId: string) {
181
+ return PaymentError.refusal('PAYMENT_DECLINED', `Payment ${paymentId} was declined.`, { paymentId });
182
+ }
183
+ }
184
+
185
+ // Instead of: a handler that reports by itself, and hides the failure from its caller.
186
+ export class CapturePaymentReportingItself {
187
+ constructor(
188
+ private readonly gateway: PaymentGateway,
189
+ private readonly errors: ErrorReporter,
190
+ ) {}
191
+
192
+ async execute(command: CapturePaymentCommand): Promise<void> {
193
+ try {
194
+ await this.gateway.capture(command.paymentId);
195
+ } catch (error) {
196
+ this.errors.report(error, { origin: 'command', operation: 'CapturePayment', severity: 'error' });
197
+ }
198
+ }
199
+ }
200
+
201
+ // Write: a handler that throws. A refusal is a DomainError, answered and never reported;
202
+ // anything else goes up to the bus, which reports it once, with the actor, the tenant and the trace.
203
+ export class CapturePaymentHandler {
204
+ constructor(private readonly gateway: PaymentGateway) {}
205
+
206
+ async execute(command: CapturePaymentCommand): Promise<void> {
207
+ if ((await this.gateway.capture(command.paymentId)) === 'declined') {
208
+ throw PaymentError.declined(command.paymentId);
209
+ }
210
+ }
211
+ }
212
+ ```
213
+
101
214
  ## With OpenTelemetry
102
215
 
103
216
  `@ddd-ts-kit/tracing` owns the tracer provider, the context manager, the propagator and the
@@ -227,4 +340,5 @@ Put the DSN in `SENTRY_DSN`; from another container of the stack, replace `local
227
340
  ## In a test
228
341
 
229
342
  Nothing of this package: the test application binds `InMemoryErrorReporter`, and
230
- `app.errors.reports` lists what the buses and the jobs reported, as data.
343
+ `app.errors.reports` lists what the buses and the jobs reported, as data: each entry is
344
+ `{ error, report }`, the error as it was thrown and the `ErrorReport` that describes it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ddd-ts-kit/sentry",
3
- "version": "0.16.0",
3
+ "version": "0.16.1",
4
4
  "description": "Error reporting to Sentry or GlitchTip behind the kernel ErrorReporter port: unexpected failures only, correlated with traces, scrubbed before sending.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -26,12 +26,12 @@
26
26
  "dist"
27
27
  ],
28
28
  "dependencies": {
29
- "@ddd-ts-kit/kernel": "0.16.0"
29
+ "@ddd-ts-kit/kernel": "0.16.1"
30
30
  },
31
31
  "devDependencies": {
32
- "@ddd-ts-kit/di": "0.16.0",
33
- "@ddd-ts-kit/http": "0.16.0",
34
- "@ddd-ts-kit/tracing": "0.16.0",
32
+ "@ddd-ts-kit/di": "0.16.1",
33
+ "@ddd-ts-kit/http": "0.16.1",
34
+ "@ddd-ts-kit/tracing": "0.16.1",
35
35
  "@opentelemetry/api": "^1.9.1",
36
36
  "@opentelemetry/sdk-trace-base": "^2.0.0",
37
37
  "@sentry/node": "^11.6.0"