@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.
Files changed (3) hide show
  1. package/README.fr.md +558 -0
  2. package/README.md +131 -3
  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
+ ![La place du package : la plateforme enveloppe chaque endroit où le travail entre dans un contexte délimité ou en sort, et chaque enveloppe ouvre une sorte de span. tracedRoutes autour du pipeline HTTP ouvre le span SERVER de la requête HTTP. traceApplication, donné une fois à kernelModule, réunit les enveloppes qui ouvrent des spans : traceUseCases sur les bus ouvre les spans command et query, traceBindings sur les repositories, les gateways et les gestionnaires ouvre leurs spans, tracedDomainEvents sur le répartiteur des événements du domaine ouvre les spans unit-of-work et domain-event, tracedPublisher sur le publisher du noyau ouvre outbox append, tracedConsumer ouvre process, et tracedJob et tracedBackgroundJob ouvrent job ; il confie aussi au traçage le rapporteur d'erreurs et la portée d'exécution. tracedPublisher autour du publisher du relais de l'outbox ouvre publish. initTracing démarre le SDK OpenTelemetry avec ses instrumentations et ses processeurs de spans, et exporte en OTLP vers Jaeger ou un collecteur. Le code des contextes n'en importe rien](https://raw.githubusercontent.com/gbourgeat/ddd-ts-kit/main/docs/assets/diagrams/tracing-package-place.fr.svg)
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
+ ![Une seule trace à travers deux chaînes d'appels. Dans la requête HTTP, le span POST /appointments contient la commande, qui contient, à travers les spans unit-of-work, domain-event et handler, outbox append ; outbox append appose son propre traceparent sur l'événement, et l'outbox le stocke avec l'enregistrement. Plus tard, le relais réclame l'enregistrement depuis un minuteur, sans span à lui ; tracedPublisher ouvre publish comme enfant du span que nomme l'enregistrement, et appose son propre traceparent sur le message, dans l'enveloppe et dans un en-tête. Chaque consommateur ouvre process comme enfant de publish. L'identifiant de trace est le même de la requête HTTP jusqu'aux consommateurs](https://raw.githubusercontent.com/gbourgeat/ddd-ts-kit/main/docs/assets/diagrams/tracing-package-across-the-outbox.fr.svg)
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
+ ![Les deux façons dont un consommateur se rattache à la publication. Avec consumers: continue, le défaut, les spans process des deux consommateurs sont des enfants de publish, dans la trace de la requête HTTP : une action est une trace. Avec consumers: link, publish termine la trace de la requête HTTP, et chaque livraison démarre une trace à elle, dont le span racine process porte un lien vers publish](https://raw.githubusercontent.com/gbourgeat/ddd-ts-kit/main/docs/assets/diagrams/tracing-package-continue-or-link.fr.svg)
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
+ ![Where the package sits: the platform wraps each place where work enters or leaves a bounded context, and each wrapper opens one kind of span. tracedRoutes around the HTTP pipeline opens the SERVER span of the request. traceApplication, given once to kernelModule, holds the wrappers that open spans: traceUseCases on the buses opens command and query spans, traceBindings on repositories, gateways and handlers opens their spans, tracedDomainEvents on the domain event dispatcher opens the unit-of-work and domain-event spans, tracedPublisher on the publisher of the kernel opens outbox append, tracedConsumer opens process, and tracedJob and tracedBackgroundJob open job; it also hands the error reporter and the execution scope to the tracing. tracedPublisher around the publisher of the outbox relay opens publish. initTracing starts the OpenTelemetry SDK with its instrumentations and span processors, and exports over OTLP to Jaeger or a collector. The code of the contexts imports none of it](https://raw.githubusercontent.com/gbourgeat/ddd-ts-kit/main/docs/assets/diagrams/tracing-package-place.svg)
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
+ ![One trace across two call chains. In the request, the span POST /appointments contains the command, which contains, through the unit-of-work, domain-event and handler spans, outbox append; outbox append stamps its own traceparent on the event, and the outbox stores it with the record. Later, the relay claims the record from a timer, with no span of its own; tracedPublisher opens publish as a child of the span the record names, and stamps its own traceparent on the message, in the envelope and in a header. Each consumer opens process as a child of publish. The trace id is the same from the request to the consumers](https://raw.githubusercontent.com/gbourgeat/ddd-ts-kit/main/docs/assets/diagrams/tracing-package-across-the-outbox.svg)
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
+ ![The two ways a consumer relates to the publication. With consumers: continue, the default, the process spans of the two consumers are children of publish, in the trace of the request: one action is one trace. With consumers: link, publish ends the trace of the request, and each delivery starts a trace of its own, whose root process span carries a link to publish](https://raw.githubusercontent.com/gbourgeat/ddd-ts-kit/main/docs/assets/diagrams/tracing-package-continue-or-link.svg)
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. `spanContextFromTraceparent` accepts
239
- version `00` only and rejects all-zero ids. `withSpan(name, fn, attributes?)` exists for
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.0",
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.0"
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.0",
66
- "@ddd-ts-kit/di": "0.16.0",
67
- "@ddd-ts-kit/events": "0.16.0",
68
- "@ddd-ts-kit/http": "0.16.0",
69
- "@ddd-ts-kit/scheduling": "0.16.0",
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",