@ddd-ts-kit/tracing 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 +558 -0
- package/README.md +131 -3
- package/package.json +7 -7
package/README.fr.md
ADDED
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
[English](README.md) · **Français**
|
|
2
|
+
<!-- translation-of: README.md sha256:860413972237a22f -->
|
|
3
|
+
|
|
4
|
+
# @ddd-ts-kit/tracing
|
|
5
|
+
|
|
6
|
+
Un traçage OpenTelemetry que le domaine ne voit jamais. Rien dans `domain/` ni dans
|
|
7
|
+
`application/` n'importe ce *package* : les cas d'usage, les adaptateurs, les événements et les
|
|
8
|
+
*jobs* sont enveloppés là où ils sont câblés, dans la racine de composition, et une action de
|
|
9
|
+
l'utilisateur se lit comme une seule trace.
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
npm i @ddd-ts-kit/tracing @opentelemetry/api
|
|
13
|
+
# pour le démarrage Node (@ddd-ts-kit/tracing/node)
|
|
14
|
+
npm i @opentelemetry/sdk-node @opentelemetry/auto-instrumentations-node @opentelemetry/exporter-trace-otlp-http
|
|
15
|
+
# pour le banc de test (@ddd-ts-kit/tracing/testing)
|
|
16
|
+
npm i -D @opentelemetry/sdk-trace-base @opentelemetry/context-async-hooks
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+

|
|
20
|
+
|
|
21
|
+
Chaque *span* d'une trace est ouvert par l'un de ces quelques endroits, tous dans la plateforme.
|
|
22
|
+
Le domaine, l'application et les adaptateurs d'un contexte restent de simples classes, ce que
|
|
23
|
+
tient la règle `tracing/platform-only` : un import de `@ddd-ts-kit/tracing` ou de
|
|
24
|
+
`@opentelemetry/*` ailleurs sous la racine des sources est signalé. Au lieu d'ouvrir un *span*
|
|
25
|
+
dans un adaptateur :
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
// src/contexts/billing/infrastructure/external/stripe-payment.gateway.ts
|
|
29
|
+
import { trace } from '@opentelemetry/api'; // signalé par tracing/platform-only
|
|
30
|
+
|
|
31
|
+
export class StripePaymentGateway implements PaymentGateway {
|
|
32
|
+
async charge(invoiceId: string): Promise<void> {
|
|
33
|
+
await trace.getTracer('billing').startActiveSpan('charge', () => this.client.charge(invoiceId));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
écrivez l'adaptateur seul, et liez-le avec sa sorte : l'instrumentation du module du noyau
|
|
39
|
+
l'enveloppe, et chaque appel est un *span* `external PaymentGateway.charge` avec les mêmes
|
|
40
|
+
attributs que tout autre adaptateur.
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
export class StripePaymentGateway implements PaymentGateway {
|
|
44
|
+
async charge(invoiceId: string): Promise<void> {
|
|
45
|
+
await this.client.charge(invoiceId);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// le module du contexte, style explicite ; le style déclaré écrit plutôt
|
|
50
|
+
// @Adapter(PaymentGateway, { kind: 'external' }) sur la classe
|
|
51
|
+
builder.bind(PaymentGateway).toClass(StripePaymentGateway, [StripeClient]).as('external');
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Mise en place
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
// platform/tracing.ts : importé avant tout le reste
|
|
58
|
+
import { initTracing } from '@ddd-ts-kit/tracing/node';
|
|
59
|
+
|
|
60
|
+
export const tracing = initTracing({ serviceName: 'clinic-api' });
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
// main.ts
|
|
65
|
+
import { tracing } from '@/platform/tracing'; // premier import, voir « Ordre de chargement »
|
|
66
|
+
import { createApplication, kernelModule } from '@ddd-ts-kit/di';
|
|
67
|
+
import { traceApplication, tracedPublisher, tracedRoutes } from '@ddd-ts-kit/tracing';
|
|
68
|
+
|
|
69
|
+
const app = createApplication({
|
|
70
|
+
modules: [kernelModule({ instrumentation: traceApplication(), eventPublisher: outbox }), schedulingModule, notificationModule],
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
mountExpress(server, routes, { executionScope: app.resolve(ExecutionScope), around: tracedRoutes() });
|
|
74
|
+
new OutboxRelay({ store, publisher: tracedPublisher(bus), clock }).start({ intervalMs: 1_000 });
|
|
75
|
+
|
|
76
|
+
await app.start();
|
|
77
|
+
process.on('SIGTERM', () => void app.stop().then(() => tracing.shutdown()));
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
C'est tout : `traceApplication()` est la valeur dont `kernelModule` enveloppe l'application, et
|
|
81
|
+
aucune classe n'en sait rien. Trois endroits restent hors du module du noyau et prennent chacun
|
|
82
|
+
une enveloppe : le pipeline HTTP (`around: tracedRoutes()`), le *publisher* avec lequel un relais
|
|
83
|
+
de l'outbox envoie (`tracedPublisher(bus)`), et un projet qui construit son propre fournisseur de
|
|
84
|
+
traceurs (voir « Processeurs de spans »).
|
|
85
|
+
|
|
86
|
+
### Jaeger, en cinq lignes
|
|
87
|
+
|
|
88
|
+
Jaeger v2 reçoit l'OTLP nativement, il n'y a donc rien entre l'application et Jaeger :
|
|
89
|
+
|
|
90
|
+
```yaml
|
|
91
|
+
services:
|
|
92
|
+
jaeger:
|
|
93
|
+
image: jaegertracing/jaeger:2.22.0
|
|
94
|
+
profiles: [observability]
|
|
95
|
+
ports: ['127.0.0.1:16686:16686', '127.0.0.1:4318:4318']
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
docker compose --profile observability up -d jaeger
|
|
100
|
+
OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318 node dist/main.js
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`initTracing({ serviceName })` n'a besoin de rien d'autre : le point d'accès (endpoint) vient de
|
|
104
|
+
la variable standard `OTEL_EXPORTER_OTLP_ENDPOINT` (ou `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT`), et
|
|
105
|
+
sans aucune des deux rien n'est exporté. Ouvrez `http://127.0.0.1:16686` et cherchez le service.
|
|
106
|
+
|
|
107
|
+
### Ordre de chargement
|
|
108
|
+
|
|
109
|
+
L'auto-instrumentation modifie `http` et les pilotes de base de données au moment où ils sont
|
|
110
|
+
chargés, donc `initTracing` doit s'être exécuté avant. Dans un module ES, les imports statiques
|
|
111
|
+
sont évalués avant la première instruction du fichier : gardez l'appel dans son propre module et
|
|
112
|
+
importez ce module en premier (comme ci-dessus), ou préchargez-le avec
|
|
113
|
+
`node --import ./dist/platform/tracing.js`. Les *packages* publiés en modules ES ont aussi besoin
|
|
114
|
+
du *hook* de chargement `@opentelemetry/instrumentation`.
|
|
115
|
+
|
|
116
|
+
## À quoi ressemble une trace
|
|
117
|
+
|
|
118
|
+
Une requête HTTP qui réserve un rendez-vous, l'annonce à deux autres contextes délimités par
|
|
119
|
+
l'outbox, et est publiée par le relais un instant plus tard, depuis un minuteur et hors de toute
|
|
120
|
+
requête HTTP :
|
|
121
|
+
|
|
122
|
+
```
|
|
123
|
+
POST /appointments SERVER scheduling
|
|
124
|
+
command BookAppointment INTERNAL
|
|
125
|
+
repository AppointmentRepository.findOverlapping INTERNAL
|
|
126
|
+
repository AppointmentRepository.save INTERNAL
|
|
127
|
+
unit-of-work before-commit INTERNAL après chaque enregistrement, avant la validation
|
|
128
|
+
domain-event scheduling.appointment.booked INTERNAL
|
|
129
|
+
handler AppointmentBookedPublisher INTERNAL
|
|
130
|
+
outbox append scheduling.appointment.booked INTERNAL (la réponse part une fois ceci validé)
|
|
131
|
+
publish scheduling.appointment.booked PRODUCER par le relais, plus tard, même trace
|
|
132
|
+
process notification.appointment-booked CONSUMER notification
|
|
133
|
+
command SendBookingConfirmation INTERNAL
|
|
134
|
+
external MailGateway.send INTERNAL
|
|
135
|
+
process analytics.appointment-booked CONSUMER analytics
|
|
136
|
+
command RecordBooking INTERNAL
|
|
137
|
+
repository BookingRepository.save INTERNAL
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Voici l'arbre tel qu'il est produit, vérifié *span* par *span* par
|
|
141
|
+
`test/scenarios/one-action-one-trace.spec.ts` et, contre un vrai Jaeger, par
|
|
142
|
+
`test/jaeger/real-jaeger.spec.ts`. Un *job* planifié est une trace à part entière :
|
|
143
|
+
|
|
144
|
+
```
|
|
145
|
+
job scheduling.send-reminders INTERNAL (racine)
|
|
146
|
+
command SendReminders INTERNAL
|
|
147
|
+
repository AppointmentRepository.findOverlapping INTERNAL
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### Les phases d'une unité de travail
|
|
151
|
+
|
|
152
|
+
Les gestionnaires d'événements du domaine ne sont pas sous l'enregistrement qui a consigné
|
|
153
|
+
l'événement : l'unité de travail les distribue une fois que le gestionnaire de la commande a
|
|
154
|
+
rendu la main. Sous le *span* de la commande, dans l'ordre :
|
|
155
|
+
|
|
156
|
+
```
|
|
157
|
+
command SendParcels INTERNAL
|
|
158
|
+
repository ParcelRepository.save INTERNAL
|
|
159
|
+
repository ParcelRepository.save INTERNAL
|
|
160
|
+
unit-of-work before-commit INTERNAL les événements des deux enregistrements
|
|
161
|
+
domain-event shipping.parcel.sent INTERNAL
|
|
162
|
+
repository ParcelRepository.save INTERNAL un gestionnaire enregistre un autre agrégat
|
|
163
|
+
domain-event shipping.parcel.sent INTERNAL
|
|
164
|
+
unit-of-work before-commit INTERNAL ce que les gestionnaires ont consigné à leur tour
|
|
165
|
+
domain-event shipping.parcel.tracked INTERNAL
|
|
166
|
+
unit-of-work after-commit INTERNAL une fois validé, même trace
|
|
167
|
+
domain-event shipping.parcel.sent INTERNAL
|
|
168
|
+
domain-event shipping.parcel.sent INTERNAL
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
- `unit-of-work before-commit` suit les *spans* d'enregistrement. Ce que les gestionnaires ont
|
|
172
|
+
consigné à leur tour se trouve sous un second *span* du même nom.
|
|
173
|
+
- `unit-of-work after-commit` est présent quand un événement a un gestionnaire de cette phase, et
|
|
174
|
+
ne contient que ces événements. C'est un enfant du *span* de la commande, et il reste dans la
|
|
175
|
+
trace quand son gestionnaire échoue : le *span* de la commande dit toujours `succeeded`.
|
|
176
|
+
- Un gestionnaire déclaré avec une sorte (`@DomainEventHandler`) est un *span*
|
|
177
|
+
`handler <ClassName>` sous son *span* `domain-event` ; une fonction de rappel attachée avec
|
|
178
|
+
`dispatcher.on` n'en a pas.
|
|
179
|
+
- Hors de toute unité de travail (un *repository* appelé seul), les *spans* `domain-event`
|
|
180
|
+
restent sous le *span* de l'enregistrement qui les a distribués, sans *span* de phase.
|
|
181
|
+
|
|
182
|
+
Cet arbre est vérifié par `test/unit-of-work-phases.spec.ts`.
|
|
183
|
+
|
|
184
|
+
### Noms, sortes et attributs des spans
|
|
185
|
+
|
|
186
|
+
| Span | Sorte | Créé par | Attributs |
|
|
187
|
+
|---|---|---|---|
|
|
188
|
+
| `<METHOD> <route template>` | SERVER | `tracedRoutes()` (renomme le *span* qu'une instrumentation HTTP a ouvert, ou en démarre un) | `http.route`, `http.request.method`, `http.response.status_code`, `app.operation`, `app.correlation_id`, `app.origin`, `app.outcome`, `app.error.code` |
|
|
189
|
+
| `command <Name>`, `query <Name>` | INTERNAL | `traceUseCases()`, un *middleware* du bus | `app.use_case.kind`, `app.use_case.name`, `app.context`, `app.outcome`, `app.error.code`, `error.type` |
|
|
190
|
+
| `<kind> <Port>.<method>` : `repository`, `gateway`, `external`, `publisher` | INTERNAL | `traceBindings()` | `code.namespace` (la classe de l'adaptateur), `code.function`, `app.dependency.kind`, `app.context` |
|
|
191
|
+
| `unit-of-work before-commit` | INTERNAL | `tracedDomainEvents()`, autour de chaque vague d'événements distribués à la fin d'une unité de travail, avant la validation (commit) | aucun |
|
|
192
|
+
| `unit-of-work after-commit` | INTERNAL | `tracedDomainEvents()`, autour des événements qui ont un gestionnaire `after-commit`, une fois l'unité de travail validée | aucun |
|
|
193
|
+
| `domain-event <name>` | INTERNAL | `tracedDomainEvents()` | `app.domain_event.name` |
|
|
194
|
+
| `handler <ClassName>` | INTERNAL | `traceBindings()`, sorte `domain-event-handler` | `code.namespace`, `code.function`, `app.dependency.kind`, `app.context` |
|
|
195
|
+
| `outbox append <type>` | INTERNAL | `tracedPublisher()` autour d'un *publisher* qui stocke | `messaging.system` (`outbox`), `messaging.operation.name` (`append`), `messaging.operation.type` (`create`), `messaging.destination.name`, `messaging.message.id` |
|
|
196
|
+
| `publish <type>` | PRODUCER | `tracedPublisher()` autour d'un bus | `messaging.system`, `messaging.operation.name` (`publish`), `messaging.operation.type` (`send`), `messaging.destination.name`, `messaging.message.id` |
|
|
197
|
+
| `process <consumer>` | CONSUMER | `tracedConsumer()`, un par livraison | `messaging.system`, `messaging.operation.*` (`process`), `messaging.destination.name`, `messaging.message.id`, `messaging.consumer.group.name`, `app.messaging.source`, `app.messaging.attempt`, `app.messaging.redelivered`, `app.context`, `app.correlation_id`, `app.origin`, `app.outcome` |
|
|
198
|
+
| `job <name>` | INTERNAL, toujours une racine | `tracedJob()` | `app.job.name`, `app.job.scheduled_at`, `app.job.attempt`, `app.context`, `app.correlation_id`, `app.origin`, `app.outcome` |
|
|
199
|
+
| `job <name>` d'un *job* en arrière-plan | CONSUMER, enfant du *span* qui l'a mis en file | `tracedBackgroundJob()`, un par tentative ; `traceparent()` (`captureTraceparent`) est stocké avec le *job* quand il est mis en file | `app.job.name`, `app.job.id`, `app.job.attempt`, `app.context`, `app.correlation_id`, `app.origin` (`queue`), `app.outcome` |
|
|
200
|
+
|
|
201
|
+
- Les noms sont stables et de faible cardinalité : un gabarit de route, jamais une URL ; une
|
|
202
|
+
classe, un port, un type d'événement ou un nom de consommateur, jamais un identifiant.
|
|
203
|
+
- `app.outcome` vaut `succeeded`, `refused` (une réponse métier), `failed`, et pour une livraison
|
|
204
|
+
`dead-lettered`. Une `DomainError` est une réponse, pas un incident : le *span* du cas d'usage
|
|
205
|
+
dit `refused` et `app.error.code`, et n'est pas une erreur. Tout le reste met le statut ERROR
|
|
206
|
+
avec `error.type` (le nom de la classe) et `app.error.code` (le `code`, quand il en a la forme).
|
|
207
|
+
- **Une requête HTTP refusée par l'autorisation n'a pas de span de cas d'usage** : le
|
|
208
|
+
*middleware* se place après la garde du cas d'usage, si bien que la trace se réduit au *span*
|
|
209
|
+
SERVER, avec le statut renvoyé et le code (`FORBIDDEN`). Un cas d'usage refusé par ses propres
|
|
210
|
+
règles a son *span*, `refused`.
|
|
211
|
+
- **Un seul mécanisme par span.** Un cas d'usage est tracé par le *middleware* du bus, un
|
|
212
|
+
consommateur par `process <consumer>`, un *job* par `job <name>` : `traceBindings()` laisse de
|
|
213
|
+
côté les liaisons de sorte `use-case`, `consumer` et `job`.
|
|
214
|
+
- **Vie privée** : classe, méthode, port, contexte, codes et les identifiants du kit
|
|
215
|
+
(identifiant d'événement, identifiant de corrélation, nom de *job*). Jamais les arguments, les
|
|
216
|
+
résultats, les charges utiles, les valeurs des paramètres SQL, les messages d'erreur, les
|
|
217
|
+
identifiants d'agrégat, ni quoi que ce soit sur l'acteur ou le *tenant*.
|
|
218
|
+
|
|
219
|
+
### À travers la frontière asynchrone
|
|
220
|
+
|
|
221
|
+
Le contexte de trace est capturé quand l'événement d'intégration est créé, dans la requête HTTP :
|
|
222
|
+
`outbox append <type>` appose son propre `traceparent` sur l'événement, et l'outbox le stocke avec
|
|
223
|
+
l'enregistrement (chaque stockage d'outbox du kit le persiste et le restitue exactement : la
|
|
224
|
+
suite de contrat de `@ddd-ts-kit/testing` l'affirme pour les stockages en mémoire, Drizzle, Knex,
|
|
225
|
+
Prisma et TypeORM). Le relais publie plus tard, depuis une autre chaîne d'appels ou un autre
|
|
226
|
+
processus : `tracedPublisher(bus)` fait de `publish <type>` un enfant de ce que nomme
|
|
227
|
+
l'enregistrement, jamais de la boucle du relais, et appose son propre *span* sur ce qui part, si
|
|
228
|
+
bien que les consommateurs sont ses enfants. Un tour qui ne trouve rien à publier ne crée aucun
|
|
229
|
+
*span*, et il n'y a pas de *span* par tour.
|
|
230
|
+
|
|
231
|
+

|
|
232
|
+
|
|
233
|
+
Un événement sans `traceparent` (stocké avant que le traçage soit configuré) poursuit plutôt le
|
|
234
|
+
*span* actif.
|
|
235
|
+
|
|
236
|
+
**Poursuivre ou lier.** Les conventions de messagerie d'OpenTelemetry permettent à un
|
|
237
|
+
consommateur de poursuivre la trace du producteur ou d'en démarrer une nouvelle qui la référence
|
|
238
|
+
par un lien. Par défaut, il poursuit, `traceApplication({ consumers: 'continue' })` : suivre une
|
|
239
|
+
action de bout en bout revient alors à ouvrir une seule trace, et Jaeger ne montre un lien que
|
|
240
|
+
comme une référence à suivre d'un clic, un consommateur à la fois. Choisissez
|
|
241
|
+
`consumers: 'link'` pour un système où un événement se déploie vers tant de consommateurs, ou est
|
|
242
|
+
retenté si longtemps, qu'une trace ne finirait jamais : chaque livraison est alors la racine de
|
|
243
|
+
sa propre trace, liée à la publication.
|
|
244
|
+
|
|
245
|
+

|
|
246
|
+
|
|
247
|
+
**Les livraisons.** Un fournisseur dit à chaque gestionnaire de quelle livraison il s'agit
|
|
248
|
+
(`EventDelivery` du noyau : tentative, dernière tentative, transport, source, consommateur), et
|
|
249
|
+
chaque tentative est un *span* à part : `app.messaging.attempt` compte à partir de 1,
|
|
250
|
+
`app.messaging.redelivered` marque celles qui suivent la première, une tentative en échec est une
|
|
251
|
+
erreur avec le code de ce qui a été levé, et la tentative après laquelle le fournisseur abandonne
|
|
252
|
+
l'événement dit `app.outcome: dead-lettered`. RabbitMQ, Kafka, NATS, Redis Streams et le bus en
|
|
253
|
+
mémoire le disent tous ; leur `traceparent` voyage dans l'enveloppe et dans un vrai en-tête
|
|
254
|
+
(`traceparent`).
|
|
255
|
+
|
|
256
|
+
## Les pièces, sans `kernelModule`
|
|
257
|
+
|
|
258
|
+
`traceApplication()`, ce sont ces pièces, une par membre, pour une composition écrite à la main :
|
|
259
|
+
|
|
260
|
+
```ts
|
|
261
|
+
bus.use(traceUseCases()); // après l'autorisation, hors de l'unité de travail
|
|
262
|
+
traceDependency(new KnexOrderRepository(db), { kind: 'repository', context: 'ordering', port: 'OrderRepository' });
|
|
263
|
+
tracedDomainEvents(dispatcher);
|
|
264
|
+
tracedPublisher(new OutboxPublisher(store)); // outbox append <type>
|
|
265
|
+
tracedPublisher(bus); // publish <type>
|
|
266
|
+
tracedSubscriber(bus, { trace: 'continue' }); // process <type>
|
|
267
|
+
tracedConsumer(handler, { consumer: 'billing.invoice-on-order-placed', context: 'billing' });
|
|
268
|
+
tracedJob(run, { name: 'billing.send-reminders', context: 'billing' });
|
|
269
|
+
tracedErrorReporter(reporter);
|
|
270
|
+
createApplication({ modules, decorate: traceBindings() }); // les liaisons seulement
|
|
271
|
+
configureTracing({ executionScope }); // identifiant de corrélation sur les spans et dans les champs de journal
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
Les deux dernières lignes sont pour une application dont le module du noyau ne reçoit aucune
|
|
275
|
+
`instrumentation`. Avec elle, `traceBindings()` est déjà le membre `binding` que le module du
|
|
276
|
+
noyau place autour de chaque liaison, et la portée d'exécution est transmise par le module, si
|
|
277
|
+
bien que le *hook* `decorate` n'ajoute rien (envelopper deux fois une instance est sans effet).
|
|
278
|
+
Un projet généré par la CLI n'a aucune des deux lignes : il appelle `traceApplication()` une fois
|
|
279
|
+
dans `src/platform/lay-foundations.ts`, `tracedRoutes()` dans
|
|
280
|
+
`src/platform/auth/http-boundary.ts`, `tracedPublisher(...)` dans
|
|
281
|
+
`src/platform/compose-application.ts`, et `initTracing` dans `src/platform/tracing.ts`. La règle
|
|
282
|
+
`tracing/platform-only` garde ces imports dans le répertoire de la plateforme, `main.ts` et
|
|
283
|
+
`apps/`.
|
|
284
|
+
|
|
285
|
+
Un *publisher* nomme lui-même son transport (`messagingSystem` : `rabbitmq`, `kafka`, `nats`,
|
|
286
|
+
`redis`, `in-memory`, et `outbox` pour le *publisher* de l'outbox) ; `scopedPublisher` et
|
|
287
|
+
`validatingPublisher` le transmettent, et `tracedPublisher(publisher, { system })` le dit pour un
|
|
288
|
+
*publisher* à vous.
|
|
289
|
+
|
|
290
|
+
Pour un transport sur mesure : `captureTraceparent()` au stockage d'un message,
|
|
291
|
+
`runWithTraceparent(traceparent, fn)` à son traitement. C'est tout ce que fait l'outbox, et cela
|
|
292
|
+
s'exécute tel quel :
|
|
293
|
+
|
|
294
|
+
<!-- checked sample -->
|
|
295
|
+
```ts
|
|
296
|
+
import { captureTraceparent, runWithTraceparent, withSpan } from '@ddd-ts-kit/tracing';
|
|
297
|
+
import { expectSpanTree, installTestTracing } from '@ddd-ts-kit/tracing/testing';
|
|
298
|
+
|
|
299
|
+
interface QueuedMessage {
|
|
300
|
+
readonly body: string;
|
|
301
|
+
readonly traceparent: string | undefined;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const queue: QueuedMessage[] = [];
|
|
305
|
+
const tracing = installTestTracing();
|
|
306
|
+
|
|
307
|
+
const trace = await tracing.traceOf(async () => {
|
|
308
|
+
// Dans la requête HTTP : stocker le message avec la trace à laquelle il appartient.
|
|
309
|
+
await withSpan('POST /messages', async () => {
|
|
310
|
+
queue.push({ body: 'hello', traceparent: captureTraceparent() });
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
// Plus tard, depuis un minuteur, hors de tout span : poursuivre la trace stockée.
|
|
314
|
+
for (const message of queue.splice(0)) {
|
|
315
|
+
await runWithTraceparent(message.traceparent, () => withSpan('handle message', async () => undefined));
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
// Une seule trace : le traitement est un enfant de la requête HTTP, bien qu'elle soit terminée.
|
|
320
|
+
expectSpanTree(trace, [{ name: 'POST /messages', children: [{ name: 'handle message' }] }]);
|
|
321
|
+
tracing.uninstall();
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
Avec un `traceparent` absent ou malformé, `runWithTraceparent` exécute la fonction dans le
|
|
325
|
+
contexte courant. `spanContextFromTraceparent` n'accepte que la version `00` et rejette les
|
|
326
|
+
identifiants entièrement nuls. `withSpan(name, fn, attributes?)` existe pour le code de la
|
|
327
|
+
plateforme (une migration, une étape de démarrage) ; le code du domaine et de l'application ne
|
|
328
|
+
l'appelle jamais.
|
|
329
|
+
|
|
330
|
+
## Ce que contient un span de dépendance
|
|
331
|
+
|
|
332
|
+
`traceDependency` ouvre un *span* par appel d'une méthode asynchrone, nommé
|
|
333
|
+
`<kind> <Port>.<method>` d'après le port quand il est donné et d'après la classe sinon.
|
|
334
|
+
`traceBindings()` l'appelle pour chaque liaison déclarée avec une sorte ; à la main, et vérifié
|
|
335
|
+
avec le banc de test :
|
|
336
|
+
|
|
337
|
+
<!-- checked sample -->
|
|
338
|
+
```ts
|
|
339
|
+
import { traceDependency, withSpan } from '@ddd-ts-kit/tracing';
|
|
340
|
+
import { expectSpanTree, installTestTracing } from '@ddd-ts-kit/tracing/testing';
|
|
341
|
+
|
|
342
|
+
// Un adaptateur tel qu'un contexte l'écrit : aucun import du package de traçage.
|
|
343
|
+
class InMemoryOrderRepository {
|
|
344
|
+
private readonly rows = new Map<string, number>();
|
|
345
|
+
|
|
346
|
+
async save(orderId: string, total: number): Promise<void> {
|
|
347
|
+
this.rows.set(orderId, total);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async totalOf(orderId: string): Promise<number | undefined> {
|
|
351
|
+
return this.rows.get(orderId);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const tracing = installTestTracing();
|
|
356
|
+
|
|
357
|
+
// La racine de composition l'enveloppe, sous le nom de son port.
|
|
358
|
+
const orders = traceDependency(new InMemoryOrderRepository(), {
|
|
359
|
+
kind: 'repository',
|
|
360
|
+
context: 'ordering',
|
|
361
|
+
port: 'OrderRepository',
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
const trace = await tracing.traceOf(() =>
|
|
365
|
+
withSpan('place order', async () => {
|
|
366
|
+
await orders.save('order-1', 3000);
|
|
367
|
+
await orders.totalOf('order-1');
|
|
368
|
+
}),
|
|
369
|
+
);
|
|
370
|
+
|
|
371
|
+
expectSpanTree(trace, [
|
|
372
|
+
{
|
|
373
|
+
name: 'place order',
|
|
374
|
+
children: [
|
|
375
|
+
{
|
|
376
|
+
name: 'repository OrderRepository.save',
|
|
377
|
+
attributes: { 'code.namespace': 'InMemoryOrderRepository', 'code.function': 'save', 'app.context': 'ordering' },
|
|
378
|
+
},
|
|
379
|
+
{ name: 'repository OrderRepository.totalOf' },
|
|
380
|
+
],
|
|
381
|
+
},
|
|
382
|
+
]);
|
|
383
|
+
|
|
384
|
+
console.log(orders instanceof InMemoryOrderRepository); // true : le même objet, enveloppé sur place
|
|
385
|
+
tracing.uninstall();
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
Ce que l'enveloppe garde et ce qu'elle laisse de côté :
|
|
389
|
+
|
|
390
|
+
- Jamais les arguments, les valeurs de retour ni les messages d'erreur ; l'erreur est relancée
|
|
391
|
+
intacte.
|
|
392
|
+
- Les méthodes synchrones ne sont pas tracées. Une méthode déclarée `async` est tracée dès son
|
|
393
|
+
premier appel ; une méthode qui se contente de renvoyer une promesse est tracée à partir de son
|
|
394
|
+
deuxième appel. Un appel tracé renvoie une promesse, n'enveloppez donc pas un objet dont les
|
|
395
|
+
méthodes renvoient un constructeur de requêtes chaînable.
|
|
396
|
+
- Les accesseurs (getters), les symboles, les générateurs et les *hooks* de cycle de vie
|
|
397
|
+
(`onModuleInit`, `onModuleDestroy`, `onApplicationBootstrap`, `onApplicationShutdown`,
|
|
398
|
+
`dispose`) sont laissés tels quels.
|
|
399
|
+
- L'instance est enveloppée sur place : même objet, `instanceof` et `this` intacts, envelopper
|
|
400
|
+
deux fois est sans effet. Rien d'énumérable n'est ajouté, si bien que l'instance se décompose
|
|
401
|
+
(spread) et se compare comme avant.
|
|
402
|
+
- `private` est un mot-clé de compilation : à l'exécution, une méthode privée est une méthode, et
|
|
403
|
+
une fonction tenue dans un champ privé est une méthode aussi. Les deux sont tracées quand elles
|
|
404
|
+
sont asynchrones, et une méthode qui en appelle une autre par `this` produit un *span* enfant.
|
|
405
|
+
Les aspects de `@ddd-ts-kit/aspects` diffèrent à dessein : ils n'interviennent que sur l'appel
|
|
406
|
+
fait de l'extérieur.
|
|
407
|
+
- Une instance gelée ou scellée est placée derrière un proxy à la place. Ses méthodes et ses
|
|
408
|
+
accesseurs s'exécutent avec la vraie instance comme `this` ; les appels qu'elle fait sur
|
|
409
|
+
elle-même ne sont alors pas tracés.
|
|
410
|
+
- **Ordre des enveloppes d'un repository** sous `kernelModule({ instrumentation })`, de la plus
|
|
411
|
+
externe à la plus interne : le *hook* `decorate` de l'application quand il y en a un, le *span*,
|
|
412
|
+
la remise des événements du domaine (`dispatchingOnSave` du noyau), puis les aspects de la
|
|
413
|
+
liaison (`retry`, `timeout`). Ainsi, `repository X.save` couvre les nouvelles tentatives, et un
|
|
414
|
+
enregistrement retenté annonce ses événements une seule fois. Dans une unité de travail,
|
|
415
|
+
`repository X.save` ne contient aucun *span* `domain-event` : l'enregistrement ne fait que mettre
|
|
416
|
+
les événements en file, et ils sont distribués sous `unit-of-work before-commit`, un frère qui
|
|
417
|
+
suit les enregistrements. Hors de toute unité de travail, l'enregistrement distribue avant de se
|
|
418
|
+
résoudre, et les *spans* `domain-event` sont ses enfants.
|
|
419
|
+
|
|
420
|
+
## Bruit
|
|
421
|
+
|
|
422
|
+
Une trace raconte l'histoire d'une action et rien d'autre. Les valeurs par défaut, et l'option
|
|
423
|
+
pour chacune :
|
|
424
|
+
|
|
425
|
+
| Quoi | Par défaut | Option |
|
|
426
|
+
|---|---|---|
|
|
427
|
+
| Sondes, métriques, documentation | aucun *span* pour `/health`, `/healthz`, `/live`, `/livez`, `/ready`, `/readyz`, `/startup`, `/metrics`, `/docs`, `/openapi.json`, `/openapi` (les documents d'un contrat découpé), `/favicon.ico` et ce qui est en dessous ; aucun *span* pour les routes de documentation de `@ddd-ts-kit/http`, où qu'elles soient montées | `initTracing({ ignoredPaths })`, `tracedRoutes({ ignore })` |
|
|
428
|
+
| Relais de l'outbox | aucun *span* par tour, aucun quand rien n'est publié ; les requêtes de son interrogation périodique ne créent aucun *span* (voir les pilotes) | |
|
|
429
|
+
| Instrumentations actives | `http`, `undici` et `grpc` (les deux côtés d'une requête HTTP), `pg`, `mysql2`, `mysql`, `mongodb`, `ioredis`, `redis` | `initTracing({ autoInstrumentations })`, ou `instrumentations` pour toutes les remplacer |
|
|
430
|
+
| Pilotes de base de données | la requête SQL telle qu'écrite, avec ses paramètres substituables, jamais les valeurs qui leur sont liées ; seulement dans une trace (`requireParentSpan`), si bien qu'un processus d'interrogation ou un pool ne crée aucune trace à lui ; aucun *span* de connexion ni d'emprunt au pool (`ignoreConnectSpans`) | `autoInstrumentations` |
|
|
431
|
+
| Instrumentations inactives | `fs`, `dns`, `net` (comment un appel a été fait, pas ce qui a été fait) ; `express`, `connect`, `router`, `koa`, `hapi`, `restify`, `nestjs-core` (un *span* par *middleware*, là où le kit nomme la requête HTTP par sa route) ; `amqplib`, `kafkajs` (ils doubleraient `publish` et `process`) ; `knex`, `generic-pool`, `dataloader`, `lru-memoizer`, `mongoose` (ils répètent le pilote) ; les ponts de journalisation, les métriques d'exécution et de l'hôte | `autoInstrumentations: { '@opentelemetry/instrumentation-fs': { enabled: true } }` |
|
|
432
|
+
| Attributs | une liste d'autorisation (`DEFAULT_ALLOWED_ATTRIBUTES`) : ce que le kit écrit et ce que les instrumentations actives disent d'une requête HTTP, d'une requête SQL, d'un pair. Les en-têtes, les corps, les valeurs des paramètres, les identifiants d'utilisateur et de client sont retirés avant l'export | `initTracing({ allowedAttributes })`, `new AttributeAllowList({ allowed, alsoAllowed })` |
|
|
433
|
+
| Volume | sous une même racine locale (une requête HTTP, une livraison, une exécution de *job*), au plus 25 *spans* de dépendance, d'événement du domaine ou de publication partagent un nom et 200 existent au total ; au-delà, l'appel s'exécute sans trace (un événement publié au-delà du plafond porte quand même la trace plus loin) et `app.dependency_spans.dropped` compte sur la racine | `configureTracing({ maxSpansPerName, maxSpansPerRoot })` |
|
|
434
|
+
| Secrets | les valeurs de la chaîne de requête et les informations d'utilisateur (userinfo) des URL enregistrées deviennent `REDACTED` ; les messages d'exception et les piles d'appels sont retirés | |
|
|
435
|
+
|
|
436
|
+
Les *spans* de cas d'usage, de consommateur, de *job* et de requête HTTP ne sont jamais plafonnés : il
|
|
437
|
+
y en a un par unité de travail.
|
|
438
|
+
|
|
439
|
+
### Échantillonnage
|
|
440
|
+
|
|
441
|
+
`initTracing({ sampleRatio: 0.2 })` échantillonne une part des traces là où elles commencent, et
|
|
442
|
+
un parent échantillonné est toujours suivi : une trace est gardée ou abandonnée en entier, à
|
|
443
|
+
travers l'outbox et les contextes, puisque la décision voyage dans le `traceparent` stocké avec
|
|
444
|
+
l'événement. Sans l'option, les variables standard `OTEL_TRACES_SAMPLER` s'appliquent, et par
|
|
445
|
+
défaut toutes les traces sont gardées.
|
|
446
|
+
|
|
447
|
+
Garder toutes les traces qui se terminent en erreur, quel que soit le ratio, ne peut pas se
|
|
448
|
+
décider au début d'une trace. C'est l'échantillonnage en queue (tail sampling), et il revient à
|
|
449
|
+
un collecteur, qui voit les traces entières. Échantillonnez tout dans l'application et décidez
|
|
450
|
+
là-bas :
|
|
451
|
+
|
|
452
|
+
```yaml
|
|
453
|
+
# OpenTelemetry Collector (contrib) : entre les applications et Jaeger
|
|
454
|
+
receivers:
|
|
455
|
+
otlp: { protocols: { http: { endpoint: 0.0.0.0:4318 } } }
|
|
456
|
+
processors:
|
|
457
|
+
tail_sampling:
|
|
458
|
+
decision_wait: 30s
|
|
459
|
+
policies:
|
|
460
|
+
- { name: errors, type: status_code, status_code: { status_codes: [ERROR] } }
|
|
461
|
+
- { name: dead-letters, type: string_attribute, string_attribute: { key: app.outcome, values: [dead-lettered] } }
|
|
462
|
+
- { name: the-rest, type: probabilistic, probabilistic: { sampling_percentage: 10 } }
|
|
463
|
+
exporters:
|
|
464
|
+
otlp_http/jaeger: { endpoint: http://jaeger:4318 }
|
|
465
|
+
service:
|
|
466
|
+
pipelines:
|
|
467
|
+
traces: { receivers: [otlp], processors: [tail_sampling], exporters: [otlp_http/jaeger] }
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
`decision_wait` doit couvrir le délai du relais de l'outbox et des nouvelles tentatives, sinon
|
|
471
|
+
les *spans* tardifs d'une trace sont décidés à part.
|
|
472
|
+
|
|
473
|
+
## Processeurs de spans
|
|
474
|
+
|
|
475
|
+
Pour un fournisseur que vous construisez vous-même, ajoutez-les avant le processeur qui exporte :
|
|
476
|
+
|
|
477
|
+
| Processeur | Effet |
|
|
478
|
+
|---|---|
|
|
479
|
+
| `LocalRootTracker` | active les plafonds par racine, nomme la corrélation sur la racine locale, et permet à `tracedRoutes()` de reconnaître le *span* d'une instrumentation HTTP |
|
|
480
|
+
| `UrlSecretSpanProcessor` | `url.full`, `http.url`, `url.query` : les valeurs de la chaîne de requête et les informations d'utilisateur deviennent `REDACTED`, les noms des paramètres restent |
|
|
481
|
+
| `ErrorMessageScrubber` | retire `exception.message`, `exception.stacktrace` et le message de statut enregistré par les instrumentations ; garde `exception.type` |
|
|
482
|
+
| `AttributeAllowList` | ne garde que les attributs autorisés |
|
|
483
|
+
|
|
484
|
+
`initTracing` installe les quatre, puis `extraSpanProcessors`, puis l'exportateur.
|
|
485
|
+
|
|
486
|
+
## Passer d'un outil à l'autre : trace, journaux, journal d'audit, suivi des erreurs
|
|
487
|
+
|
|
488
|
+
Chaque *span* qui démarre une unité de travail (le *span* SERVER, `process <consumer>`,
|
|
489
|
+
`job <name>`) porte `app.correlation_id` et `app.origin` : l'identifiant de corrélation du kit, le
|
|
490
|
+
même dans chaque contexte délimité qu'une action atteint. À partir de là :
|
|
491
|
+
|
|
492
|
+
```ts
|
|
493
|
+
const logger = pino({ mixin: () => traceLogFields() });
|
|
494
|
+
// dans une unité de travail : { correlationId, origin, actorId, tenantId?, causationId?, trace_id, span_id }
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
| De | Vers | Par |
|
|
498
|
+
|---|---|---|
|
|
499
|
+
| une trace | ses lignes de journal | `trace_id` des champs de journal, ou `correlationId` |
|
|
500
|
+
| une trace | les entrées d'audit de l'action | `app.correlation_id` est le `correlationId` de l'`AuditEntry` |
|
|
501
|
+
| un rapport d'erreur | sa trace | `traceId` de l'`ErrorReport`, rempli par `tracedErrorReporter` (aussi pour un *job* en échec ou un événement enterré, signalés une fois leurs *spans* terminés) |
|
|
502
|
+
| une ligne de journal, une entrée d'audit | la trace | cherchez le tag `app.correlation_id` dans Jaeger |
|
|
503
|
+
|
|
504
|
+
`traceApplication()` donne la portée d'exécution de l'application à `configureTracing` ; une
|
|
505
|
+
composition à la main appelle elle-même `configureTracing({ executionScope })`, et
|
|
506
|
+
`traceLogFields(scope)` prend la portée en argument pour un *logger* construit plus tôt.
|
|
507
|
+
`correlationIdFromActiveSpan()` renvoie l'identifiant de trace, utilisable comme référence de
|
|
508
|
+
support quand le projet n'a pas de portée d'exécution. **Rien de personnel ne devient un
|
|
509
|
+
attribut de span** : l'acteur et le *tenant* restent dans les champs de journal, dont la durée de
|
|
510
|
+
conservation vous appartient.
|
|
511
|
+
|
|
512
|
+
## Vérifier les traces dans un projet
|
|
513
|
+
|
|
514
|
+
```ts
|
|
515
|
+
import { expectSpanTree, installTestTracing } from '@ddd-ts-kit/tracing/testing';
|
|
516
|
+
|
|
517
|
+
const tracing = installTestTracing(); // un vrai fournisseur qui garde les spans terminés ; uninstall() à la fin
|
|
518
|
+
|
|
519
|
+
it('books an appointment in one trace', async () => {
|
|
520
|
+
const trace = await tracing.traceOf(() => app.resolve(CommandBus).execute(new BookAppointment('09:00')));
|
|
521
|
+
|
|
522
|
+
expectSpanTree(trace, [
|
|
523
|
+
{
|
|
524
|
+
name: 'command BookAppointment',
|
|
525
|
+
attributes: { 'app.context': 'scheduling', 'app.outcome': 'succeeded' },
|
|
526
|
+
children: [
|
|
527
|
+
{ name: 'repository AppointmentRepository.findOverlapping' },
|
|
528
|
+
{ name: 'repository AppointmentRepository.save' },
|
|
529
|
+
{ name: 'unit-of-work before-commit', children: [{ name: 'domain-event scheduling.appointment.booked' }] },
|
|
530
|
+
],
|
|
531
|
+
},
|
|
532
|
+
]);
|
|
533
|
+
});
|
|
534
|
+
```
|
|
535
|
+
|
|
536
|
+
- `traceOf(action)` renvoie les *spans* terminés pendant l'action sous forme de données simples
|
|
537
|
+
(`SpanNode` : nom, sorte, statut, attributs, identifiants de trace et de *span*, liens, enfants
|
|
538
|
+
dans l'ordre de leur démarrage) : un arbre par racine, donc deux traces font deux arbres.
|
|
539
|
+
- `expectSpanTree(actual, expected)` est exact sur les noms et la structure : un *span* qui n'est
|
|
540
|
+
pas listé fait échouer, tout comme un *span* listé qui manque. Les sortes, les statuts et les
|
|
541
|
+
attributs sont vérifiés quand ils sont donnés (`undefined` attend l'absence d'un attribut), et
|
|
542
|
+
un *span* est attendu sans erreur sauf si `status: 'ERROR'` le dit. En cas d'écart, l'erreur
|
|
543
|
+
liste ce qui diffère, puis montre les deux arbres sous forme de texte indenté. Il ne dépend
|
|
544
|
+
d'aucun lanceur de tests.
|
|
545
|
+
- `installTestTracing({ processors, sampler })` prend les processeurs du projet et un
|
|
546
|
+
échantillonneur, pour vérifier le nettoyage ou l'échantillonnage.
|
|
547
|
+
|
|
548
|
+
## Tests
|
|
549
|
+
|
|
550
|
+
```
|
|
551
|
+
npx vitest run packages/tracing
|
|
552
|
+
DDD_TS_KIT_TEST_JAEGER_IMAGE=jaegertracing/jaeger:2.22.0 npx vitest run packages/tracing/test/jaeger
|
|
553
|
+
```
|
|
554
|
+
|
|
555
|
+
La seconde commande a besoin de Docker : elle démarre son propre Jaeger (un conteneur nommé
|
|
556
|
+
`ddd-ts-kit-test-trace-jaeger-<pid>`, sur deux ports libres de la boucle locale), exécute le
|
|
557
|
+
scénario ci-dessus avec `initTracing({ serviceName })` et la variable standard du point d'accès,
|
|
558
|
+
relit la trace par l'API HTTP de Jaeger et vérifie le même arbre, puis supprime le conteneur.
|
package/README.md
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
**English** · [Français](README.fr.md)
|
|
2
|
+
|
|
1
3
|
# @ddd-ts-kit/tracing
|
|
2
4
|
|
|
3
5
|
OpenTelemetry tracing that the domain never sees. Nothing in `domain/` or `application/` imports
|
|
@@ -12,6 +14,40 @@ npm i @opentelemetry/sdk-node @opentelemetry/auto-instrumentations-node @opentel
|
|
|
12
14
|
npm i -D @opentelemetry/sdk-trace-base @opentelemetry/context-async-hooks
|
|
13
15
|
```
|
|
14
16
|
|
|
17
|
+

|
|
18
|
+
|
|
19
|
+
Every span of a trace is opened by one of these few places, all of them in the platform. The
|
|
20
|
+
domain, the application and the adapters of a context stay plain classes, which the rule
|
|
21
|
+
`tracing/platform-only` holds: an import of `@ddd-ts-kit/tracing` or of `@opentelemetry/*`
|
|
22
|
+
anywhere else under the source root is reported. Instead of opening a span in an adapter:
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
// src/contexts/billing/infrastructure/external/stripe-payment.gateway.ts
|
|
26
|
+
import { trace } from '@opentelemetry/api'; // reported by tracing/platform-only
|
|
27
|
+
|
|
28
|
+
export class StripePaymentGateway implements PaymentGateway {
|
|
29
|
+
async charge(invoiceId: string): Promise<void> {
|
|
30
|
+
await trace.getTracer('billing').startActiveSpan('charge', () => this.client.charge(invoiceId));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
write the adapter alone, and bind it with its kind: the instrumentation of the kernel module
|
|
36
|
+
wraps it, and every call is an `external PaymentGateway.charge` span with the same attributes
|
|
37
|
+
as every other adapter.
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
export class StripePaymentGateway implements PaymentGateway {
|
|
41
|
+
async charge(invoiceId: string): Promise<void> {
|
|
42
|
+
await this.client.charge(invoiceId);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// the module of the context, explicit style; the declared style writes
|
|
47
|
+
// @Adapter(PaymentGateway, { kind: 'external' }) on the class instead
|
|
48
|
+
builder.bind(PaymentGateway).toClass(StripePaymentGateway, [StripeClient]).as('external');
|
|
49
|
+
```
|
|
50
|
+
|
|
15
51
|
## Setup
|
|
16
52
|
|
|
17
53
|
```ts
|
|
@@ -182,6 +218,8 @@ makes `publish <type>` a child of what the record names, never of the loop of th
|
|
|
182
218
|
stamps its own span on what goes out, so the consumers are its children. A tick that finds
|
|
183
219
|
nothing to publish makes no span, and there is no span per tick.
|
|
184
220
|
|
|
221
|
+

|
|
222
|
+
|
|
185
223
|
An event without `traceparent` (stored before tracing was configured) continues the active
|
|
186
224
|
span instead.
|
|
187
225
|
|
|
@@ -193,6 +231,8 @@ time. Choose `consumers: 'link'` for a system where one event fans out to so man
|
|
|
193
231
|
or is retried for so long, that a trace would never end: each delivery is then the root of its
|
|
194
232
|
own trace, linked to the publication.
|
|
195
233
|
|
|
234
|
+

|
|
235
|
+
|
|
196
236
|
**Deliveries.** A provider tells each handler which delivery this is (`EventDelivery` of the
|
|
197
237
|
kernel: attempt, last attempt, transport, source, consumer), and each attempt is a span of its
|
|
198
238
|
own: `app.messaging.attempt` counts from 1, `app.messaging.redelivered` marks the ones after
|
|
@@ -235,14 +275,102 @@ A publisher names its transport itself (`messagingSystem`: `rabbitmq`, `kafka`,
|
|
|
235
275
|
publisher of your own.
|
|
236
276
|
|
|
237
277
|
For a custom transport: `captureTraceparent()` when storing a message,
|
|
238
|
-
`runWithTraceparent(traceparent, fn)` when handling it.
|
|
239
|
-
|
|
278
|
+
`runWithTraceparent(traceparent, fn)` when handling it. This is all the outbox does, and it
|
|
279
|
+
runs as it is:
|
|
280
|
+
|
|
281
|
+
<!-- checked sample -->
|
|
282
|
+
```ts
|
|
283
|
+
import { captureTraceparent, runWithTraceparent, withSpan } from '@ddd-ts-kit/tracing';
|
|
284
|
+
import { expectSpanTree, installTestTracing } from '@ddd-ts-kit/tracing/testing';
|
|
285
|
+
|
|
286
|
+
interface QueuedMessage {
|
|
287
|
+
readonly body: string;
|
|
288
|
+
readonly traceparent: string | undefined;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const queue: QueuedMessage[] = [];
|
|
292
|
+
const tracing = installTestTracing();
|
|
293
|
+
|
|
294
|
+
const trace = await tracing.traceOf(async () => {
|
|
295
|
+
// Inside the request: store the message with the trace it belongs to.
|
|
296
|
+
await withSpan('POST /messages', async () => {
|
|
297
|
+
queue.push({ body: 'hello', traceparent: captureTraceparent() });
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
// Later, from a timer, outside of any span: continue the stored trace.
|
|
301
|
+
for (const message of queue.splice(0)) {
|
|
302
|
+
await runWithTraceparent(message.traceparent, () => withSpan('handle message', async () => undefined));
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
// One trace: the handling is a child of the request, though the request was over.
|
|
307
|
+
expectSpanTree(trace, [{ name: 'POST /messages', children: [{ name: 'handle message' }] }]);
|
|
308
|
+
tracing.uninstall();
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
With a missing or malformed `traceparent`, `runWithTraceparent` runs the function in the
|
|
312
|
+
current context. `spanContextFromTraceparent` accepts version `00` only and rejects all-zero
|
|
313
|
+
ids. `withSpan(name, fn, attributes?)` exists for
|
|
240
314
|
platform code (a migration, a startup step); domain and application code never call it.
|
|
241
315
|
|
|
242
316
|
## What a dependency span contains
|
|
243
317
|
|
|
244
318
|
`traceDependency` opens one span per call of an async method, named `<kind> <Port>.<method>`
|
|
245
|
-
after the port when it is given and after the class otherwise.
|
|
319
|
+
after the port when it is given and after the class otherwise. `traceBindings()` calls it for
|
|
320
|
+
every binding declared with a kind; by hand, and asserted with the test harness:
|
|
321
|
+
|
|
322
|
+
<!-- checked sample -->
|
|
323
|
+
```ts
|
|
324
|
+
import { traceDependency, withSpan } from '@ddd-ts-kit/tracing';
|
|
325
|
+
import { expectSpanTree, installTestTracing } from '@ddd-ts-kit/tracing/testing';
|
|
326
|
+
|
|
327
|
+
// An adapter as a context writes it: no import of the tracing package.
|
|
328
|
+
class InMemoryOrderRepository {
|
|
329
|
+
private readonly rows = new Map<string, number>();
|
|
330
|
+
|
|
331
|
+
async save(orderId: string, total: number): Promise<void> {
|
|
332
|
+
this.rows.set(orderId, total);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async totalOf(orderId: string): Promise<number | undefined> {
|
|
336
|
+
return this.rows.get(orderId);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const tracing = installTestTracing();
|
|
341
|
+
|
|
342
|
+
// The composition root wraps it, under the name of its port.
|
|
343
|
+
const orders = traceDependency(new InMemoryOrderRepository(), {
|
|
344
|
+
kind: 'repository',
|
|
345
|
+
context: 'ordering',
|
|
346
|
+
port: 'OrderRepository',
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
const trace = await tracing.traceOf(() =>
|
|
350
|
+
withSpan('place order', async () => {
|
|
351
|
+
await orders.save('order-1', 3000);
|
|
352
|
+
await orders.totalOf('order-1');
|
|
353
|
+
}),
|
|
354
|
+
);
|
|
355
|
+
|
|
356
|
+
expectSpanTree(trace, [
|
|
357
|
+
{
|
|
358
|
+
name: 'place order',
|
|
359
|
+
children: [
|
|
360
|
+
{
|
|
361
|
+
name: 'repository OrderRepository.save',
|
|
362
|
+
attributes: { 'code.namespace': 'InMemoryOrderRepository', 'code.function': 'save', 'app.context': 'ordering' },
|
|
363
|
+
},
|
|
364
|
+
{ name: 'repository OrderRepository.totalOf' },
|
|
365
|
+
],
|
|
366
|
+
},
|
|
367
|
+
]);
|
|
368
|
+
|
|
369
|
+
console.log(orders instanceof InMemoryOrderRepository); // true: the same object, wrapped in place
|
|
370
|
+
tracing.uninstall();
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
What the wrapping keeps and leaves out:
|
|
246
374
|
|
|
247
375
|
- Never arguments, return values or error messages; the error is rethrown untouched.
|
|
248
376
|
- Synchronous methods are not traced. A method declared `async` is traced from its first call; a
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ddd-ts-kit/tracing",
|
|
3
|
-
"version": "0.16.
|
|
3
|
+
"version": "0.16.1",
|
|
4
4
|
"description": "Non-invasive OpenTelemetry tracing: adapters are wrapped at the composition root, the domain never knows.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"dist"
|
|
35
35
|
],
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@ddd-ts-kit/kernel": "0.16.
|
|
37
|
+
"@ddd-ts-kit/kernel": "0.16.1"
|
|
38
38
|
},
|
|
39
39
|
"peerDependencies": {
|
|
40
40
|
"@opentelemetry/api": "^1.9.0",
|
|
@@ -62,11 +62,11 @@
|
|
|
62
62
|
}
|
|
63
63
|
},
|
|
64
64
|
"devDependencies": {
|
|
65
|
-
"@ddd-ts-kit/audit": "0.16.
|
|
66
|
-
"@ddd-ts-kit/di": "0.16.
|
|
67
|
-
"@ddd-ts-kit/events": "0.16.
|
|
68
|
-
"@ddd-ts-kit/http": "0.16.
|
|
69
|
-
"@ddd-ts-kit/scheduling": "0.16.
|
|
65
|
+
"@ddd-ts-kit/audit": "0.16.1",
|
|
66
|
+
"@ddd-ts-kit/di": "0.16.1",
|
|
67
|
+
"@ddd-ts-kit/events": "0.16.1",
|
|
68
|
+
"@ddd-ts-kit/http": "0.16.1",
|
|
69
|
+
"@ddd-ts-kit/scheduling": "0.16.1",
|
|
70
70
|
"@opentelemetry/api": "^1.9.1",
|
|
71
71
|
"@opentelemetry/auto-instrumentations-node": "latest",
|
|
72
72
|
"@opentelemetry/context-async-hooks": "^2.0.0",
|