@onlineapps/mq-client-core 3.0.1 → 3.2.0

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/CHANGELOG.md CHANGED
@@ -4,6 +4,104 @@ All notable changes to this package. Follows [Keep a Changelog](https://keepacha
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [3.2.0] — 2026-09-15
8
+
9
+ ### Added — dead-letter cesty pro rodiny `monitoring.*` a `telemetry.*` (d.475)
10
+
11
+ Sedm front, které konzumenti monitoringu čtou — `monitoring.workflow`,
12
+ `monitoring.services`, `monitoring.audit`,
13
+ `monitoring.infrastructure.health.events`, `telemetry.logs.queue`,
14
+ `telemetry.metrics.queue`, `telemetry.traces.queue` — dostalo
15
+ `x-dead-letter-exchange: ''` a `x-dead-letter-routing-key: '<prefix>.dlq'`, a obě
16
+ rodiny svou koncovou frontu: `monitoring.dlq` a `telemetry.dlq`, deklarované
17
+ stejným tvarem jako `workflow.dlq` a `delivery.dlq` (durable, `x-max-length`
18
+ 50000, žádné TTL, žádná další cesta). TTL ani strop žádné existující fronty se
19
+ nemění.
20
+
21
+ Je to mechanické pravidlo konfirmace `mq-consumer-contract` 003 bod 1 použité
22
+ podruhé, přesně jak bod 3 předepsal („`monitoring.*`, `telemetry.*` … dostanou
23
+ `<prefix>.dlq`, až jejich konzumenti přejdou na politiku knihovny") — nová
24
+ konfirmace k tomu nepatří. Do té doby brána d.259 ty konzumenty vůbec nepustila:
25
+ `consume()` odmítá frontu, jejíž deklarace neříká, kam s odmítnutou zprávou.
26
+
27
+ Ostatní rodiny téže věty — `registry.*`, `validation.*`, `infrastructure.*` —
28
+ cestu **nedostávají**: jejich konzumenti pořád potvrzují na vlastních surových
29
+ kanálech (`infra/api_services_registry/src/listeners/registry.listener.js`,
30
+ `infra/api_services_{registry,validator}/src/services/mqManager.js` — DL-007),
31
+ a `.dlq`, do které nikdo nezapisuje, je fronta bez pisatele.
32
+
33
+ Fronty zakládá `initInfrastructureQueues()` z deklarace samotné (prochází
34
+ `infrastructurePrefixes()` a klíče sekcí), takže obě nové vzniknou při nejbližším
35
+ bootu jejího jediného volajícího, `infra/api_gateway`. Běžící `monitoring.*`
36
+ a `telemetry.*` fronty nesou staré argumenty, takže je podle konfirmace 003 bodu
37
+ 4 čeká smazání a znovuzaložení ve vlně; do té doby je `assertQueue` nad nimi
38
+ s novou deklarací 406.
39
+
40
+ ## [3.1.0] — 2026-09-15
41
+
42
+ ### Added — `topology` nese i tři telemetrické fronty (d.461)
43
+
44
+ `module.exports.topology` vystavuje `telemetryLogsQueue`, `telemetryMetricsQueue`,
45
+ `telemetryTracesQueue`, složené ze sekce `telemetry` v `queueConfig` (tatáž
46
+ deklarace, ne kopie). Monitoring, jediný konzument těch front, může zrušit
47
+ svoje privátní literály a číst jména odtud; `src/config/queueConfig` zůstává
48
+ interní cestou. README § topology popisuje celý export.
49
+
50
+ ### Fixed — `performHealthCheck()` hlásí stav, nemění ho (d.467)
51
+
52
+ Kontrola spouštěla obnovovací cyklus bezpodmínečně: `_reconnectWithBackoff()`
53
+ nastaví `_reconnecting` synchronně, `isConnected()` ho čte, a tak zdravý klient
54
+ reportoval vlastní vedlejší účinek (`healthy: false`, `Connection is closed or
55
+ missing`, `isConnected()` true → false) a obnova skutečně vyměnila živý socket
56
+ za nový. Teď se obnova spouští jen u odstaveného klienta — otázku klade jednomu
57
+ zdroji živosti (`isConnected()` nad `_connectionAlive`), tam, kde ji kladou
58
+ `_ensurePublisherChannel()` a `_ensureConsumerChannel()`. Líný kontrakt d.339
59
+ zůstává: odstavený klient se health checkem dál vrací do provozu. Repro nad
60
+ živým brokerem s `reconnectEnabled: true` (starší sady o health checku stavěly
61
+ klienta s `reconnectEnabled: false`, proto vadu nikdy neviděly).
62
+
63
+ ### Fixed — `redactUrl` se bere z `@onlineapps/logger-contract`, lokální kopie pryč (d.455)
64
+
65
+ `src/utils/redactCredentials.js` držel vlastní `redactUrl`
66
+ a `UNPARSEABLE_PLACEHOLDER`. Od `@onlineapps/logger-contract` 1.2.0 (d.453) je
67
+ vlastní core vrstva, takže je tenhle modul importuje a `src/index.js` je
68
+ **re-exportuje identitou** — veřejné API se nemění, dependants dál píší
69
+ `require('@onlineapps/mq-client-core').redactUrl`. Zdejší zůstává jen
70
+ `redactConnectionTarget`: fail-fast obal nad deklarovaným config klíčem `host`,
71
+ který v core vrstvě nemá co dělat (vyhazuje tam, kde `redactUrl` vrací
72
+ placeholder — d.292 §2/§3).
73
+
74
+ Čtyři otázky ke smazané deklaraci (`change-discipline.md` § Removing):
75
+
76
+ 1. **Proč vznikla** — d.448 sjednotila sémantiku (celé userinfo pryč, ne jen
77
+ heslo) poté, co se na `api_service_hello` 2026-09-07 změřilo heslo brokeru
78
+ ve stdout a přes monitoring i v Loki. Core domov tehdy neexistoval, takže
79
+ funkce zůstala tady.
80
+ 2. **Která část koncepce ji nesla** — `change-discipline.md` § One rail per
81
+ concern ji naopak zakazuje: tři kopie jednoho pravidla (tady,
82
+ `service-common`, `conn-orch-registry`). Ta část koncepce stojí dál.
83
+ 3. **Proč ji dnes nikdo nečte** — `@onlineapps/logger-contract` 1.2.0 exportuje
84
+ `redactUrl` i `UNPARSEABLE_PLACEHOLDER` a všechny tři dependants míří na něj.
85
+ 4. **Je náhrada koncepčnější** — ano: `logger-contract` je L1 bez `@onlineapps`
86
+ závislostí, takže na něj dosáhne i kategorie `orchestration`, které brána G7
87
+ odmítá pin do kategorie `runtime`.
88
+
89
+ **Změna chování na jednom okraji:** zdejší kopie vracela `parsed.href` bez
90
+ úpravy, logger-contract navíc utne lomítko, které WHATWG dopisuje prázdné cestě
91
+ u *special* schemat (`http`, `https`, `ws`, `wss`, `ftp`, `file`). Na `amqp` /
92
+ `amqps` — jediných, se kterými tenhle klient pracuje — jsou obě implementace
93
+ bajtově shodné; `http://u:p@host:8080` nově vrátí `http://host:8080` místo
94
+ `http://host:8080/`.
95
+
96
+ Sada `tests/unit/redact-credentials.test.js`: blok se sémantikou `redactUrl`
97
+ case-by-case odešel (drží ho logger-contract), místo něj drží identitu
98
+ re-exportu, nepřítomnost druhé koleje v `utils/redactCredentials`
99
+ (`Object.keys` = `['redactConnectionTarget']`), shodu výstupu
100
+ `redactConnectionTarget` s `loggerContract.redactUrl` a cestu selhání.
101
+ Zmizely i mrtvé citace na `shared/service-common/src/redactUrl.js`
102
+ a `shared/connector/conn-orch-registry/src/redactUrl.js` — oba soubory už
103
+ neexistují.
104
+
7
105
  ## [3.0.1] — 2026-09-14
8
106
 
9
107
  - **Fixed: `consume()` accepts a TERMINAL queue — one this configuration dead-letters TO — instead of demanding a dead-letter route it must not have.** The d.259 gate refuses a queue for which `queueConfig` declares no `x-dead-letter-exchange`/`-routing-key`, because the policy ends a spent message with `nack(requeue=false)` and the broker drops what it cannot move. True of every working queue — and false of the queues that RECEIVE those moves: `workflow.failed` is named in `x-dead-letter-routing-key` by `workflow.init` and `workflow.control`, `<service>.dlq` by every business template, `workflow.dlq` and `delivery.dlq` by their own families, and the confirmed topology leaves exactly those without a route of their own (`api/docs/governance/confirmations/mq-consumer-contract.md` 002 point 2, 003 point 2 — "workflow.failed and workflow.dlq themselves keep no route"). A dead-letter route on a dead-letter queue is a loop or a chain with no end. So `workflow.failed` — the operator's queue, read by the delivery dispatcher — was unconsumable: `[BaseClient] Cannot consume from queue "workflow.failed": queueConfig declares no dead-letter route`. The gate now asks a second question, `queueConfig.isDeadLetterTarget(queue)`, **derived from the templates** (the set of every declared `x-dead-letter-routing-key`, `{service}` placeholder read through the same `templateShape()` the business templates use) rather than from a list of names that would have to be edited in step with them — the defect d.278 removed from `matchBusinessTemplate()`. The `.dlq` family needs no rule of its own: every `.dlq` this configuration declares is already the target of a declared routing key (measured 2026-09-14). Every other queue with no route is refused exactly as before, with the same sentence. A message that spends its budget on a terminal queue is rejected the same way and then DROPPED by the broker — the end of the chain is the end — never requeued for ever, and the loss is now logged at error level (`Message discarded on terminal queue "<queue>"`, with the queue, the attempts and the error) before the `message_dlq` event is published. Tests: new unit `consume-terminal-queue.test.js` (10 — the derivation from the declared keys, the terminal names and the non-terminal control, `consume('workflow.failed')` and `consume('<svc>.dlq')` attaching, the three refusals that must not change with the refusal verbatim, the discard with its log line and meta, and a routed queue as the control that says nothing about a discard); integration `dead-letter-route-required.integration.test.js` (+2 against the live broker: a message rejected from `<svc>.queue` reaches `<svc>.dlq` and is DELIVERED to a consumer there with `x-death[0].reason = 'rejected'`, and a handler failing on that terminal queue leaves the queue empty with no second delivery after 2 s — the drop no mock can measure). Three tests that encoded the old rule follow the new one: the `<svc>.dlq` refusal leg of `consume-dead-letter-route-required.test.js` (its subject moved to the new file), and the two integration control queues that used a dead-letter queue as "the queue with no route" (`delivery-events-lookup` → `monitoring.workflow`, `queue-declaration-options-one-rail` → `<svc>.events`).
@@ -36,7 +134,7 @@ All notable changes to this package. Follows [Keep a Changelog](https://keepacha
36
134
  - **Removed (BREAKING: `persistentBufferEnabled` and `persistentRedisClient` are refused by name):** the persistent publish buffer, which never existed. `buffer/RedisBuffer.js` was a placeholder — `add()` stored nothing, `flush()` returned 0 — and `MessageBuffer.add()` returned after that branch without falling back (the stub did not throw, so there was nothing to fall back from). With `persistentBufferEnabled: true` a critical-priority message that failed transiently was therefore DROPPED, while the same message at normal priority was held in memory and replayed after the reconnect: the key inverted its own promise. The package's own concept node already said so in writing (`docs/architecture/mq-publish-reliability.md` § Buffering: "RedisBuffer is a stub … Leave persistentBufferEnabled at its default") — a mechanism documented as not existing (`automation-gates.md` §5). The four questions of `change-discipline.md` § Removing are answered in the commit and in `tests/unit/persistent-buffer-removed.test.js`: it arrived with the publish-reliability layer (`3588b148`) as a hook for a future extension; no part of the concept requires persistence; nothing ever set either key outside one test (measured across `api`, `api_biz`, `infra`, `fe_adminui`, 2026-09-14); and what stands in its place — one in-memory buffer holding every priority — is more conceptual than a switch that loses messages. `getBufferState()` is therefore `{ size, inMemory }` (the `persistent` count could only ever be 0), `MessageBuffer.flush()` returns the number replayed instead of `{ inMemory, persistent }`, and both key names now fail the constructor with a sentence saying the mechanism is gone rather than a name to rename them to (d.343).
37
135
  - **Fixed (BREAKING: `publish()` no longer creates the exchange it publishes to, and `options.exchangeType` is gone):** an exchange is NAMED by a publisher, never declared by one. `_publishOnce()` asserted the exchange before EVERY publish, with arguments it made up on the spot — the type from `options.exchangeType || 'direct'` and the durability from the PUBLISHING client's own config — so the publisher decided the topology and two differently configured publishers declared one exchange two ways. Measured on the live broker: a client configured `durable: false` publishing to a fanout its owner declared durable is refused with `406 PRECONDITION-FAILED … inequivalent arg 'type' … received 'direct' but current is 'fanout'`, on a message that had nothing wrong with it. The owner's decision names this exact defect one object down — "the endpoint's own `{ durable: true }` assert is a second declaration and already yields 406 against the library's" (`docs/governance/confirmations/mq-consumer-contract.md` 003, point 4). Exchanges are declared once in `config/queueConfig.js` and asserted by their owners (measured: every exchange the platform publishes to already is), so an exchange nobody declared is now a broker-side 404 on the publish rather than an exchange invented by whoever published first — the same rule as `README.md` § Queue ownership states for queues. `options.exchangeType` existed for that one assert and left with it, including its only caller inside the library, `monitoring-publish.js` (d.342).
38
136
  - **Added:** `BaseClient.isConnectionFatal()` and `BaseClient.performHealthCheck()` — the last two methods a service could not reach. Same hole d.296 closed for the five counters, different question: both were methods of the TRANSPORT alone, and `new RabbitMQClient` is called in exactly one place in the whole workspace (`transports/transportFactory.js`, inside this library), so a service holding a `BaseClient` saw `isConnected() === false` with no way to learn whether to wait or to restart, and the manual probe `docs/architecture/rabbitmq-channel-lifecycle.md` documents was callable on nothing it holds. The transport keeps the computation; the client delegates. `performHealthCheck()` follows the five counters and refuses before `connect()` built a transport; `isConnectionFatal()` deliberately does not — a client that never connected has a true answer (no), and a healthcheck must not be answered by an exception. `onChannelClose` stays the transport's: it is an internal hook on a channel's lifetime, not a question a service asks. The d.296 gate now covers both new counterparts (d.341).
39
- - **Removed (BREAKING for a caller importing the transport):** `require('@onlineapps/mq-client-core').RabbitMQClient`. The four questions, answered before deleting: it came in with the library's extraction (`7a47b2fa`), as part of publishing the module's surface; no part of the concept ever carried it — `docs/architecture/mq-publish-reliability.md` describes the transport as what `BaseClient` selects, never as what a caller constructs; nothing reads it because `transportFactory` has been the one constructor since that same commit (measured across `api_biz/*`, `infra/*`, `shared/*`: zero uses outside this package); and what stands in its place is more conceptual, not less — the factory picks the transport from `type` (Configuration Over Convention), while constructing it by hand bypasses config composition, schema validation and the instance registry `disconnectAll()` is built on (d.341).
137
+ - **Removed (BREAKING for a caller importing the transport):** `require('@onlineapps/mq-client-core').RabbitMQClient`. The four questions, answered before deleting: it came in with the library's extraction (`7a47b2fa`), as part of publishing the module's surface; no part of the concept ever carried it — `docs/architecture/mq-publish-reliability.md` describes the transport as what `BaseClient` selects, never as what a caller constructs; nothing reads it because `transportFactory` has been the one constructor since that same commit (measured across `api_biz/*`, `infra/*`, `shared/*`: zero uses outside this package — that scope skipped `api/tests/**`, which carried 13 uses in 3 suites; INFRA retires them as a second rail, measured 2026-09-15); and what stands in its place is more conceptual, not less — the factory picks the transport from `type` (Configuration Over Convention), while constructing it by hand bypasses config composition, schema validation and the instance registry `disconnectAll()` is built on (d.341).
40
138
  - **Fixed (BREAKING: a spent reconnect budget is no longer the end of the client):** after the attempt budget of one recovery cycle runs out against a broker that never answered, the client **stands down** — `connection:standby` with `{ cycle, cyclesMax, attempts, lastError, timestamp }`, no timer, no socket, `isConnected()` false and `isConnectionFatal()` FALSE — and the next `publish()`, `consume()` or `performHealthCheck()` starts the next cycle. Nothing polls in between, so a client nobody uses costs nothing while the broker is away. Until now one spent budget set the fatal flag and the client refused every operation for the life of the process, which the README taught as "dead until the process restarts"; that is the "give up forever" the owner forbade (`docs/governance/confirmations/mq-client-lifecycle-contract.md` 001 point 3), and it meant a broker restart longer than `maxReconnectAttempts × reconnectMaxDelay` needed a process restart to undo. One rail: the lazy retry re-enters the same `_reconnectWithBackoff()`, there is no second loop (d.339).
41
139
  - **Added:** `maxReconnectCycles` (default 3, env `RABBITMQ_MAX_RECONNECT_CYCLES`) — how many recovery cycles the client runs before the connection IS permanently lost. It is what keeps the lazy retry finite; the value is the one `maxDeliveryAttempts` already answers the same question with (how many whole retries one thing gets), and two answers to one question must not differ (d.339).
42
140
  - **Changed (BREAKING for a caller reading the fatal error):** `connection:fatal` says WHICH of the two endings it is — `reason: 'broker-refused'` or `'cycles-spent'` — and carries `cycles` beside `attempts`, with a different sentence for each. A broker that ANSWERED and refused (403 `ACCESS-REFUSED`, 530 `NOT-ALLOWED`, 406 `PRECONDITION-FAILED`) is fatal on the spot, with no further attempt spent on an answer that cannot change: measured against amqplib 0.10.9 on the live broker (2026-09-14), such a refusal arrives as a plain `Error` with `code` undefined and the AMQP reply code rendered into the message, so the reply code is what is read — the broker's protocol constant, never wording invented here. Not recognised, and said rather than implied: a wrong vhost reaches amqplib as `Expected ConnectionOpenOk; got <ConnectionClose channel:0>`, so it is treated as an outage and ends at the cycle cap (d.339).
package/README.md CHANGED
@@ -218,7 +218,8 @@ carries `{ queue, messageCount, consumerCount }` and no arguments (measured, 202
218
218
 
219
219
  **A TERMINAL queue is consumed, and it is not an exception to that rule but its other
220
220
  half.** A queue something else dead-letters TO — `<service>.dlq`, `workflow.failed`,
221
- `workflow.dlq`, `delivery.dlq` — declares no onward route because the topology says it
221
+ `workflow.dlq`, `delivery.dlq`, `monitoring.dlq`, `telemetry.dlq` — declares no onward
222
+ route because the topology says it
222
223
  must not have one: a dead-letter route on a dead-letter queue closes a loop or starts a
223
224
  chain with no end (`api/docs/governance/confirmations/mq-consumer-contract.md` 002
224
225
  point 2, 003 point 2). So the gate asks a second question, `queueConfig.isDeadLetterTarget(queue)`,
@@ -565,6 +566,42 @@ file moves. Callers holding the deep path switch to the named export; callers th
565
566
  receive `queueConfig` by injection (`initInfrastructureQueues` in
566
567
  `@onlineapps/infrastructure-tools`) pass it from here.
567
568
 
569
+ ### topology — platform queue names, as a declared export
570
+
571
+ A name that travels ON THE WIRE between services has to have ONE owner, or it has one
572
+ copy per service. `topology` is that owner: a frozen object of the platform names, each
573
+ one composed from the entry that DECLARES it in `queueConfig`, so a rename there moves
574
+ the export with it and an entry that is gone fails fast instead of handing back a
575
+ plausible string.
576
+
577
+ ```javascript
578
+ const { topology } = require('@onlineapps/mq-client-core');
579
+
580
+ topology.infrastructureHealthEventsExchange; // 'infrastructure.health.events'
581
+ topology.telemetryLogsQueue; // 'telemetry.logs.queue'
582
+ topology.telemetryLogsQueue = 'mine'; // TypeError — the object is frozen
583
+ ```
584
+
585
+ | Key | Name on the wire | Who publishes / who drains it |
586
+ |---|---|---|
587
+ | `infrastructureHealthEventsExchange` | `infrastructure.health.events` | Fanout. The registry publishes (`api/infra/api_services_registry/src/services/infrastructureEventPublisher.js`); the two queues below are bound to it. |
588
+ | `monitoringInfrastructureHealthEventsQueue` | `monitoring.infrastructure.health.events` | The monitoring consumer's queue on that fanout (`api/infra/api_monitoring/src/consumer/topology.js`, `MONITORING_INFRA_EVENT_QUEUE`). |
589
+ | `deliveryHealthEventsQueue` | `delivery.health.events` | The delivery endpoint binds it to the fanout and consumes it for WS push (`api/infra/api_delivery_endpoint/src/index.js`, `bindQueueToFanout(config.healthEventsQueue, …)`). |
590
+ | `deliveryResourceEventsQueue` | `delivery.resource.events` | Bound to the `monitoring.resource` exchange by `resource.changed.#`; published by the monitoring watcher `resourceChangedPublisher.js`, drained by `ResourceChangedConsumer.js` in the delivery endpoint. |
591
+ | `telemetryLogsQueue` | `telemetry.logs.queue` | Bound to `telemetry.exchange`, which every service publishes to with its own name as the routing key; drained by the monitoring consumer (`api/infra/api_monitoring/src/consumer/index.js`, `topology.TELEMETRY_LOGS_QUEUE`). |
592
+ | `telemetryMetricsQueue` | `telemetry.metrics.queue` | The same exchange, the same consumer — metric samples. |
593
+ | `telemetryTracesQueue` | `telemetry.traces.queue` | The same exchange, the same consumer — spans. |
594
+
595
+ - **Frozen on purpose.** An importer must not be able to rewrite platform topology: a
596
+ name overridden on one side of a fanout loses the events silently, which is why batch
597
+ 243g-C removed the per-service env override for the health-events exchange in the
598
+ first place.
599
+ - **The named export is the contract.** `@onlineapps/mq-client-core/src/config/queueConfig`
600
+ is an internal path this package never promised — it resolves today and breaks the day
601
+ the file moves. Import `topology` (or `queueConfig`) from the package root.
602
+ - A service that still carries one of these names as a private literal is carrying the
603
+ spelling of a fact declared here; importing it changes the wire by nothing at all.
604
+
568
605
  ### redactUrl — a connection URL that is safe to log
569
606
 
570
607
  `RABBITMQ_URL` carries the broker account, so anything that renders it verbatim
@@ -589,9 +626,10 @@ redactUrl(undefined); // '<unparseable-url>'
589
626
  goes through `redactConnectionTarget()` instead, which refuses a non-string
590
627
  outright, because `host` is a declared config key and a wrong type there is a
591
628
  boot-time defect.
592
- - It is the SAME function this package uses internally, and the same semantics as
593
- `shared/service-common/src/redactUrl.js`. A dependant that renders a broker URL
594
- imports it from here rather than keeping a copy.
629
+ - It is the SAME function this package uses internally. Implementaci vlastní
630
+ `@onlineapps/logger-contract` (vrstva L1, dosáhne na ni každá kategorie);
631
+ tenhle balíček ji od d.455 jen **re-exportuje identitou**, aby dependant,
632
+ který renderuje URL brokeru, importoval funkci místo kopie.
595
633
 
596
634
  ## Architecture
597
635
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onlineapps/mq-client-core",
3
- "version": "3.0.1",
3
+ "version": "3.2.0",
4
4
  "description": "Core MQ client library for RabbitMQ - shared by infrastructure services and connectors",
5
5
  "oa": {
6
6
  "category": "connector"
@@ -23,8 +23,8 @@
23
23
  "author": "OnlineApps",
24
24
  "license": "MIT",
25
25
  "dependencies": {
26
- "@onlineapps/logger-contract": "1.1.0",
27
- "@onlineapps/runtime-config": "1.1.0",
26
+ "@onlineapps/logger-contract": "2.0.0",
27
+ "@onlineapps/runtime-config": "1.2.0",
28
28
  "ajv": "^8.12.0",
29
29
  "amqplib": "^0.10.3"
30
30
  },
@@ -613,6 +613,15 @@ module.exports = {
613
613
  *
614
614
  * CRITICAL: Monitoring is an infrastructure service, monitoring queues are infrastructure queues!
615
615
  * All services (both infrastructure and business) publish monitoring events here.
616
+ *
617
+ * Every QUEUE of this family dead-letters to `monitoring.dlq` over the DEFAULT
618
+ * exchange, where the routing key IS the destination queue's name — the mechanical
619
+ * rule of confirmation `mq-consumer-contract` 003 point 1, applied here by point 3
620
+ * ("`monitoring.*` … get `<prefix>.dlq` when their consumers move onto the library
621
+ * policy"). The exchanges declared alongside them carry no arguments and therefore
622
+ * no route: an exchange holds nothing to dead-letter.
623
+ *
624
+ * @see api/docs/governance/confirmations/mq-consumer-contract.md 003
616
625
  */
617
626
  monitoring: {
618
627
  /**
@@ -627,7 +636,9 @@ module.exports = {
627
636
  durable: true,
628
637
  arguments: {
629
638
  'x-message-ttl': 300000, // 5 minutes TTL
630
- 'x-max-length': 10000
639
+ 'x-max-length': 10000,
640
+ 'x-dead-letter-exchange': '', // Default exchange: routing key IS the queue name
641
+ 'x-dead-letter-routing-key': 'monitoring.dlq'
631
642
  }
632
643
  },
633
644
 
@@ -644,7 +655,9 @@ module.exports = {
644
655
  durable: true,
645
656
  arguments: {
646
657
  'x-message-ttl': 600000, // 10 minutes TTL (longer for service tracking)
647
- 'x-max-length': 20000
658
+ 'x-max-length': 20000,
659
+ 'x-dead-letter-exchange': '',
660
+ 'x-dead-letter-routing-key': 'monitoring.dlq'
648
661
  }
649
662
  },
650
663
 
@@ -657,7 +670,9 @@ module.exports = {
657
670
  durable: true,
658
671
  arguments: {
659
672
  'x-message-ttl': 300000,
660
- 'x-max-length': 50000
673
+ 'x-max-length': 50000,
674
+ 'x-dead-letter-exchange': '',
675
+ 'x-dead-letter-routing-key': 'monitoring.dlq'
661
676
  }
662
677
  },
663
678
 
@@ -674,11 +689,39 @@ module.exports = {
674
689
  /**
675
690
  * monitoring.infrastructure.health.events - the monitoring consumer's own copy of
676
691
  * the infrastructure health events, bound to the infrastructure.health.events
677
- * fanout exchange. Declared with `durable` and nothing else, exactly as the
678
- * consumer creates it.
692
+ * fanout exchange.
693
+ *
694
+ * It carried `durable` and nothing else — the arguments the consumer asserts it
695
+ * with (`infra/api_monitoring/src/consumer/index.js`, `assertQueue(queue,
696
+ * { durable: true })`). It keeps no TTL and no cap, because nothing decided it
697
+ * should have either; what it gains is the family's dead-letter route, without
698
+ * which `consume()` refuses to attach a consumer to it at all (d.259).
679
699
  */
680
700
  'infrastructure.health.events': {
681
- durable: true
701
+ durable: true,
702
+ arguments: {
703
+ 'x-dead-letter-exchange': '',
704
+ 'x-dead-letter-routing-key': 'monitoring.dlq'
705
+ }
706
+ },
707
+
708
+ /**
709
+ * monitoring.dlq - Dead letter queue of the whole monitoring family
710
+ *
711
+ * The end of this family's chain: the workflow, services and audit queues and the
712
+ * consumer's health-events copy all reject into it over the default exchange, so
713
+ * no binding has to exist for the route to work. Declared with the arguments every
714
+ * other dead-letter queue carries — durable, capped at the same 50k, no TTL and no
715
+ * onward route — because the operator's dashboard matches `*.dlq` and a message
716
+ * that expired there is a message nobody got to decide about (confirmation
717
+ * `mq-consumer-contract` 002 point 2, 003 point 1).
718
+ */
719
+ dlq: {
720
+ durable: true,
721
+ arguments: {
722
+ // No TTL for DLQ - messages should persist
723
+ 'x-max-length': 50000 // Higher limit for DLQ
724
+ }
682
725
  }
683
726
  },
684
727
 
@@ -690,8 +733,15 @@ module.exports = {
690
733
  * own name, and these three queues are bound to it
691
734
  * (`docs/standards/monitoring-queues.md`, `docs/architecture/monitoring.md`).
692
735
  *
693
- * The values are the ones the queues are created with today; this config is their
694
- * declaration, so whoever asserts them reads it here rather than retyping it.
736
+ * The TTL and the cap are the ones the queues are created with today; this config is
737
+ * their declaration, so whoever asserts them reads it here rather than retyping it.
738
+ *
739
+ * Each of the three dead-letters to `telemetry.dlq` over the DEFAULT exchange, where
740
+ * the routing key IS the destination queue's name — the mechanical rule of
741
+ * confirmation `mq-consumer-contract` 003 point 1, applied to this family by point 3
742
+ * as its consumer moves onto the library policy.
743
+ *
744
+ * @see api/docs/governance/confirmations/mq-consumer-contract.md 003
695
745
  */
696
746
  telemetry: {
697
747
  /** telemetry.logs.queue - OpenTelemetry log records, drained into Loki. */
@@ -699,7 +749,9 @@ module.exports = {
699
749
  durable: true,
700
750
  arguments: {
701
751
  'x-message-ttl': 60000, // 1 minute TTL for unprocessed messages
702
- 'x-max-length': 100000 // Max 100k messages in queue
752
+ 'x-max-length': 100000, // Max 100k messages in queue
753
+ 'x-dead-letter-exchange': '', // Default exchange: routing key IS the queue name
754
+ 'x-dead-letter-routing-key': 'telemetry.dlq'
703
755
  }
704
756
  },
705
757
 
@@ -708,7 +760,9 @@ module.exports = {
708
760
  durable: true,
709
761
  arguments: {
710
762
  'x-message-ttl': 60000,
711
- 'x-max-length': 100000
763
+ 'x-max-length': 100000,
764
+ 'x-dead-letter-exchange': '',
765
+ 'x-dead-letter-routing-key': 'telemetry.dlq'
712
766
  }
713
767
  },
714
768
 
@@ -717,7 +771,25 @@ module.exports = {
717
771
  durable: true,
718
772
  arguments: {
719
773
  'x-message-ttl': 60000,
720
- 'x-max-length': 100000
774
+ 'x-max-length': 100000,
775
+ 'x-dead-letter-exchange': '',
776
+ 'x-dead-letter-routing-key': 'telemetry.dlq'
777
+ }
778
+ },
779
+
780
+ /**
781
+ * telemetry.dlq - Dead letter queue of the whole telemetry family
782
+ *
783
+ * The three intake queues reject into it, and it rejects nowhere: same shape as
784
+ * `workflow.dlq`, `delivery.dlq` and `monitoring.dlq` — durable, capped at 50k, no
785
+ * TTL, no onward route. A telemetry record the intake cannot process is the one an
786
+ * operator most needs to look at, and a TTL here would delete it before they did.
787
+ */
788
+ dlq: {
789
+ durable: true,
790
+ arguments: {
791
+ // No TTL for DLQ - messages should persist
792
+ 'x-max-length': 50000 // Higher limit for DLQ
721
793
  }
722
794
  }
723
795
  },
package/src/index.js CHANGED
@@ -38,7 +38,7 @@ const {
38
38
  } = require('./monitoring-publish');
39
39
  const queueConfig = require('./config/queueConfig');
40
40
  const deliveryPolicy = require('./config/deliveryPolicy');
41
- const { redactUrl, UNPARSEABLE_PLACEHOLDER } = require('./utils/redactCredentials');
41
+ const { redactUrl, UNPARSEABLE_PLACEHOLDER } = require('@onlineapps/logger-contract');
42
42
 
43
43
  // Export BaseClient as default (constructor), with additional named exports
44
44
  // NOTE: When destructuring, use: const { BaseClient } = require('@onlineapps/mq-client-core');
@@ -117,7 +117,18 @@ module.exports.topology = Object.freeze({
117
117
  * names (`api/docs/governance/confirmations/mq-consumer-contract.md` 003 — the
118
118
  * endpoint declaring its queues from `queueConfig` is INFRA's step).
119
119
  */
120
- deliveryResourceEventsQueue: queueConfig.queueName('deliveryEvents', 'resource.events')
120
+ deliveryResourceEventsQueue: queueConfig.queueName('deliveryEvents', 'resource.events'),
121
+ /**
122
+ * The monitoring consumer's OpenTelemetry log intake, bound to `telemetry.exchange`.
123
+ * Declared in `queueConfig` since d.269; the consumer carries the same literal as a
124
+ * private constant (`api/infra/api_monitoring/src/consumer/topology.js`), which is the
125
+ * shape d.270 gave an owner for the health-events names.
126
+ */
127
+ telemetryLogsQueue: queueConfig.queueName('telemetry', 'logs.queue'),
128
+ /** The same consumer's metric-sample intake on that exchange. */
129
+ telemetryMetricsQueue: queueConfig.queueName('telemetry', 'metrics.queue'),
130
+ /** The same consumer's span intake on that exchange. */
131
+ telemetryTracesQueue: queueConfig.queueName('telemetry', 'traces.queue')
121
132
  });
122
133
  /**
123
134
  * The dead-letter policy of `consume()` as a contract, not as an implementation
@@ -137,16 +148,17 @@ module.exports.deliveryPolicy = deliveryPolicy;
137
148
  * userinfo out, scheme/host/port/vhost in, and a fixed placeholder for a value that
138
149
  * is not a parseable URL.
139
150
  *
140
- * It is exported because it was already written three times here, in
141
- * `shared/service-common/src/redactUrl.js` (d.245/d.245b) and in
142
- * `shared/connector/conn-orch-registry/src/redactUrl.js` (d.446), whose header says
143
- * plainly that it stayed local only because this package declared no redactor and
144
- * masked the password alone. Two semantics for one concern are two rails
145
- * (`change-discipline.md` § One rail per concern); d.448 made them one and put it
146
- * where a dependant can import it instead of copying it.
151
+ * It is exported because it was already written three times across the platform.
152
+ * Two semantics for one concern are two rails (`change-discipline.md` § One rail
153
+ * per concern); d.448 made them one, a d.455 je přesunula tam, kam patří
154
+ * `@onlineapps/logger-contract` (vrstva L1, bez `@onlineapps` závislostí, dosáhne
155
+ * na ni každá kategorie). Zdejší export je od chvíle **re-export identitou**:
156
+ * zůstává, protože dependants ho importují odsud.
147
157
  *
148
158
  * The placeholder travels with the function: a caller comparing against it must
149
159
  * read the same literal, not retype it.
160
+ *
161
+ * @see node_modules/@onlineapps/logger-contract/src/redactUrl.js
150
162
  */
151
163
  module.exports.redactUrl = redactUrl;
152
164
  module.exports.UNPARSEABLE_PLACEHOLDER = UNPARSEABLE_PLACEHOLDER;
@@ -1784,7 +1784,22 @@ class RabbitMQClient extends EventEmitter {
1784
1784
  // The internal monitor calls `_performHealthCheck()` instead, deliberately:
1785
1785
  // a timer resuming recovery would make the retry periodic, which is the
1786
1786
  // opposite of the lazy contract.
1787
- this._resumeRecovery();
1787
+ //
1788
+ // "If this client stood down" is a QUESTION, and it is asked of the one
1789
+ // liveness source (`_connectionAlive`, read through `isConnected()` — d.260)
1790
+ // the two other uses already read it from: `_ensurePublisherChannel()` and
1791
+ // `_ensureConsumerChannel()` resume recovery inside their
1792
+ // `if (!this._connectionAlive)` branch. Until d.467 this one did not ask:
1793
+ // `_reconnectWithBackoff()` sets `_reconnecting` synchronously and
1794
+ // `isConnected()` reads it, so a check on a LIVE client reported its own
1795
+ // side effect — `connection: { exists: true, closed: true }`, the issue
1796
+ // `Connection is closed or missing`, `isConnected()` true before and false
1797
+ // after — and then really replaced a healthy socket with a new one. A
1798
+ // report that changes what it reports is not a report (INFRA 326b,
1799
+ // measured 2026-09-15 against the live broker, amqplib 0.10.9).
1800
+ if (!this.isConnected()) {
1801
+ this._resumeRecovery();
1802
+ }
1788
1803
  return this._performHealthCheck();
1789
1804
  }
1790
1805
 
@@ -12,53 +12,21 @@
12
12
  * boot, so the broker account was readable in container stdout and, through the
13
13
  * monitoring consumer, in Loki (measured 2026-09-07 on api_service_hello).
14
14
  *
15
- * `redactUrl` returns the URL with the WHOLE userinfo removed: what stays is
16
- * scheme, host, port, vhost and query — the part a reader of the log actually
17
- * came for. The account NAME goes with the password, because it is the other
18
- * half of the same credential, and because this is the redaction semantics the
19
- * platform already writes: `shared/service-common/src/redactUrl.js`
20
- * (d.245/d.245b, Redis) and `shared/connector/conn-orch-registry/src/redactUrl.js`
21
- * (d.446, AMQP). Until d.448 this module masked the password only, which made
22
- * two semantics for one concern — two rails (`change-discipline.md` § One rail
23
- * per concern). `src/index.js` exports the function so the dependants import it
24
- * instead of keeping a local copy.
15
+ * Samotné pravidlo „co smí z URL dorazit do logu“ tenhle balíček neimplementuje.
16
+ * Od d.455 je bere z `@onlineapps/logger-contract` (`redactUrl`,
17
+ * `UNPARSEABLE_PLACEHOLDER`): userinfo ven, scheme/host/port/vhost/query
18
+ * dovnitř, neparsovatelná hodnota nahrazená placeholderem místo echa. Do d.455
19
+ * tu stála bajtově stejná kopie jako v `@onlineapps/service-common` — tři
20
+ * vlastníci jednoho pravidla, tedy přesně to, co `change-discipline.md`
21
+ * § One rail per concern označuje za vadu.
25
22
  *
26
- * A value that is not a parseable URL is answered with a fixed placeholder
27
- * rather than echoed: an unparseable string is exactly the case where nobody can
28
- * say whether it holds a credential, and echoing it would be the leak this
29
- * helper exists to prevent.
30
- */
31
-
32
- const UNPARSEABLE_PLACEHOLDER = '<unparseable-url>';
33
-
34
- /**
35
- * Strip userinfo (`user:password@`) from a connection URL.
23
+ * Zdejší zůstává jediná věc, kterou core vrstva nemá proč znát:
24
+ * `redactConnectionTarget`, fail-fast obal nad DEKLAROVANÝM config klíčem.
36
25
  *
37
- * @param {string} url - connection URL, e.g. `amqp://oa_dev:secret@queuer:5672/vhost`
38
- * @returns {string} the URL without userinfo, e.g. `amqp://queuer:5672/vhost`,
39
- * or `<unparseable-url>` when the input is not a URL.
26
+ * @see node_modules/@onlineapps/logger-contract/src/redactUrl.js
40
27
  */
41
- function redactUrl(url) {
42
- if (typeof url !== 'string' || url.length === 0) {
43
- return UNPARSEABLE_PLACEHOLDER;
44
- }
45
- let parsed;
46
- try {
47
- parsed = new URL(url);
48
- } catch (_) {
49
- return UNPARSEABLE_PLACEHOLDER;
50
- }
51
- // `new URL('api_services_queuer:5672')` SUCCEEDS — it reads `api_services_queuer:`
52
- // as the scheme and `5672` as an opaque path, so the value comes back with an
53
- // empty host and no userinfo to strip. Without this guard such a value would be
54
- // echoed verbatim, which is the leak this helper exists to prevent.
55
- if (!parsed.host) {
56
- return UNPARSEABLE_PLACEHOLDER;
57
- }
58
- parsed.username = '';
59
- parsed.password = '';
60
- return parsed.href;
61
- }
28
+
29
+ const { redactUrl, UNPARSEABLE_PLACEHOLDER } = require('@onlineapps/logger-contract');
62
30
 
63
31
  /**
64
32
  * Makes a connection target safe to log.
@@ -103,4 +71,4 @@ function redactConnectionTarget(target) {
103
71
  );
104
72
  }
105
73
 
106
- module.exports = { redactConnectionTarget, redactUrl, UNPARSEABLE_PLACEHOLDER };
74
+ module.exports = { redactConnectionTarget };