@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.
- package/README.fr.md +360 -0
- package/README.md +116 -2
- 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
|
+

|
|
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
|
+

|
|
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
|
+

|
|
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
|
+

|
|
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
|
+

|
|
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
|
+

|
|
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.
|
|
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.
|
|
29
|
+
"@ddd-ts-kit/kernel": "0.16.1"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
|
-
"@ddd-ts-kit/di": "0.16.
|
|
33
|
-
"@ddd-ts-kit/http": "0.16.
|
|
34
|
-
"@ddd-ts-kit/tracing": "0.16.
|
|
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"
|