@onlineapps/mq-client-core 4.0.1 → 4.1.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 +77 -0
- package/README.md +47 -0
- package/package.json +1 -1
- package/src/config/queueConfig.js +205 -6
- package/src/index.js +21 -0
- package/src/monitoring-initiated.js +187 -0
- package/src/transports/rabbitmqClient.js +5 -3
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,83 @@ All notable changes to this package. Follows [Keep a Changelog](https://keepacha
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [4.1.0] — 2026-09-23
|
|
8
|
+
|
|
9
|
+
### Added — plánovač se dozví KONEC běhu z vlastní fronty (d.818)
|
|
10
|
+
|
|
11
|
+
`queueConfig.scheduler` nese třetí položku, `workflow.events`. Je to fronta,
|
|
12
|
+
kterou si plánovač sám naváže na existující výměnu `monitoring.workflow.fanout`
|
|
13
|
+
a čte si z ní terminální událost běhu (`completed` / `failed` / `discarded`) —
|
|
14
|
+
místo aby se ptal pozorovatele přes HTTP nebo četl databázi, která není jeho
|
|
15
|
+
(rozhodnutí vlastníka, `api/docs/governance/confirmations/scheduler.md` 007).
|
|
16
|
+
Žádný producent se nemění; od knihovny je jen deklarace.
|
|
17
|
+
|
|
18
|
+
TTL je **vyslovená, ne zděděná**: `x-message-ttl: 300000`, stejně jako u
|
|
19
|
+
sourozence `monitoring.workflow` na téže výměně. Fanout neumí filtrovat, takže
|
|
20
|
+
fronta dostane všechny workflow události platformy a plánovač většinu zahodí; co
|
|
21
|
+
stojí výpadek delší než TTL, je zapsané v 007 (události se ztratí a běh uzavře
|
|
22
|
+
per-schedule timeout, který zůstává pojistkou). Dál `x-max-length: 10000` a
|
|
23
|
+
dead-letter přes VÝCHOZÍ výměnu do `scheduler.dlq` — bez deklarované cesty by
|
|
24
|
+
`consume()` frontu vůbec nepřipojil (conf `mq-consumer-contract` 002).
|
|
25
|
+
|
|
26
|
+
Import: `queueConfig.queueName('scheduler', 'workflow.events')` složí jméno,
|
|
27
|
+
`queueConfig.declarationOptions('scheduler.workflow.events')` vrátí argumenty,
|
|
28
|
+
kterými ji služba asserteuje — druhá kopie těch čísel u služby by se s touhle
|
|
29
|
+
potkala jako 406 v den, kdy se rozejdou.
|
|
30
|
+
|
|
31
|
+
### Added — `workflow_initiated` má jednu kolej: stavitel události vedle jejího přenosu (d.819)
|
|
32
|
+
|
|
33
|
+
`monitoring.buildWorkflowInitiatedEvent()` a `monitoring.publishWorkflowInitiated()`
|
|
34
|
+
— jediný stavitel události, ze které se monitoring dozví, že běh existuje (čte
|
|
35
|
+
z ní tenanta, štítky i spouštěč a teprve jí označí běh za `active`). Dosud ji
|
|
36
|
+
stavěly dvě služby, každá po svém; rozhodnutí vlastníka
|
|
37
|
+
(`api/docs/governance/confirmations/scheduler.md` 009) dalo kolej sem, do
|
|
38
|
+
knihovny, která už vlastní přenosovou půlku téhož drátu.
|
|
39
|
+
|
|
40
|
+
Vstup: `{ workflowId, redactedCookbook, serviceName, timestamp }`. Modul
|
|
41
|
+
**neredaguje** — co je v receptu citlivé, ví jedině spec služby v registru, a
|
|
42
|
+
číst ho by knihovně přidalo závislost na specech operací; volající předává
|
|
43
|
+
observability kopii, kterou si sám postavil. A **nic nevymýšlí**: každý klíč
|
|
44
|
+
`_system` musí existovat (`hasOwnProperty`, ne pravdivost), takže
|
|
45
|
+
`tenant_uuid: null` projde jako výslovná hodnota (conf
|
|
46
|
+
`run-envelope-tenant-uuid` 001) a chybějící klíč je hlášená chyba, ne tiché
|
|
47
|
+
`null`.
|
|
48
|
+
|
|
49
|
+
Publikace jde stávající `publishToMonitoringWorkflow`, tedy fail-safe: ztracená
|
|
50
|
+
kopie se hlásí, běh se kvůli ní neodmítá. Jediná výjimka je chybějící logger —
|
|
51
|
+
bez něj by slib „každé selhání se ohlásí" neplatil.
|
|
52
|
+
|
|
53
|
+
Import: `const { monitoring } = require('@onlineapps/mq-client-core');`
|
|
54
|
+
|
|
55
|
+
### Added — jméno `monitoring.resource` má vlastníka v jádru (d.619)
|
|
56
|
+
|
|
57
|
+
Topic exchange, po které jezdí události `resource.changed.*`, byla poslední
|
|
58
|
+
stranou téhle linky bez vlastníka jména: publikující strana ji nesla jako
|
|
59
|
+
konstantu modulu (`api/infra/api_monitoring/src/consumer/watchers/
|
|
60
|
+
resourceChangedPublisher.js`, `EXCHANGE_NAME`), bindující strana jako **povinný
|
|
61
|
+
env klíč** (`MONITORING_RESOURCE_EXCHANGE` v delivery-endpointu), zatímco fronta
|
|
62
|
+
na ni navázaná (`delivery.resource.events`) má vlastníka tady od d.270/d.278.
|
|
63
|
+
Jméno, na kterém se musí shodnout tři strany jedné linky, má mít jednoho
|
|
64
|
+
vlastníka — jinak jich má tolik, kolik je míst, která ho píší, a překlep nebo
|
|
65
|
+
override na jedné straně ztrácí události tiše (týž důvod jako u fanout výměny
|
|
66
|
+
`infrastructure.health.events`, d.270).
|
|
67
|
+
|
|
68
|
+
Nově: `queueConfig.monitoring.resource` deklaruje `type: 'exchange'`,
|
|
69
|
+
`exchangeType: 'topic'`, `durable: true` — tvar, který publikující strana
|
|
70
|
+
asserteuje a který vrací i běžící broker — a `topology.monitoringResourceExchange`
|
|
71
|
+
to jméno publikuje, složené z té deklarace (`queueConfig.queueName()`), ne
|
|
72
|
+
přepsané. Import: `const { topology } = require('@onlineapps/mq-client-core');`
|
|
73
|
+
|
|
74
|
+
Doklad proti realitě, ne proti sobě: integrační tier jméno ověřuje na živém
|
|
75
|
+
brokeru (`checkExchange`) a deklarovaný typ `assertExchange`em, s kontrolním
|
|
76
|
+
případem, že tatáž výměna asserteovaná jako `fanout` končí 406 — typ je tedy
|
|
77
|
+
změřený, ne předpokládaný.
|
|
78
|
+
|
|
79
|
+
Beze změny na drátě: žádné jméno se nepřejmenovává, výměna se nezakládá (
|
|
80
|
+
deklarace není pokyn k vytvoření — zakládá ji dál vlastník prefixu). Pro INFRA
|
|
81
|
+
z toho plyne, že povinný klíč `MONITORING_RESOURCE_EXCHANGE` může po re-pinu
|
|
82
|
+
zmizet stejně jako pět jmen před ním.
|
|
83
|
+
|
|
7
84
|
## [4.0.1] — 2026-09-17
|
|
8
85
|
|
|
9
86
|
### Fixed — zrušení konzumenta brokerem přestalo být tiché (d.605)
|
package/README.md
CHANGED
|
@@ -673,6 +673,53 @@ topology.telemetryLogsQueue = 'mine'; // TypeError — the object is froz
|
|
|
673
673
|
- A service that still carries one of these names as a private literal is carrying the
|
|
674
674
|
spelling of a fact declared here; importing it changes the wire by nothing at all.
|
|
675
675
|
|
|
676
|
+
### monitoring — `workflow_initiated`, the event a run is known by
|
|
677
|
+
|
|
678
|
+
Monitoring learns that a run exists from ONE event and from nowhere else: it reads the run's
|
|
679
|
+
tenancy, its labels and its trigger out of `workflow_initiated` and marks the run active on it.
|
|
680
|
+
A service that starts runs and does not file it starts runs that are invisible for their whole
|
|
681
|
+
life. So the event has one builder, here, beside the transport half that sends it
|
|
682
|
+
([`scheduler` 009](/docs/governance/confirmations/scheduler.md)).
|
|
683
|
+
|
|
684
|
+
```javascript
|
|
685
|
+
const { monitoring } = require('@onlineapps/mq-client-core');
|
|
686
|
+
|
|
687
|
+
await monitoring.publishWorkflowInitiated({
|
|
688
|
+
mqClient,
|
|
689
|
+
logger,
|
|
690
|
+
workflowId,
|
|
691
|
+
redactedCookbook, // already redacted by the caller
|
|
692
|
+
serviceName: 'api_gateway', // who files the event
|
|
693
|
+
timestamp: startedAt, // the run's instant, not a second reading of the clock
|
|
694
|
+
context: { correlationId } // optional, for the log lines
|
|
695
|
+
});
|
|
696
|
+
```
|
|
697
|
+
|
|
698
|
+
`monitoring.buildWorkflowInitiatedEvent({ workflowId, redactedCookbook, serviceName, timestamp })`
|
|
699
|
+
composes the body alone, so a suite can state it without a broker. It carries exactly these ten
|
|
700
|
+
keys: `event_type` (`initiated`), `workflow_id`, `service_name`, `step_index` (`-1`), `step_id`
|
|
701
|
+
(`workflow_start`), `cookbook`, `status` (`initiated`), `tenant_uuid`, `tenant_id`, `timestamp`.
|
|
702
|
+
|
|
703
|
+
What it does NOT do, and both halves are rules rather than omissions:
|
|
704
|
+
|
|
705
|
+
- **It does not redact.** What is sensitive in a cookbook is known only to the service spec in
|
|
706
|
+
the registry, and reading that spec would give this package a dependency on the operation
|
|
707
|
+
specs. The caller hands over the observability copy it built; a path that cannot redact does
|
|
708
|
+
not file the event at all, because an empty set of sensitive fields is silent consent to an
|
|
709
|
+
unredacted copy.
|
|
710
|
+
- **It invents no value.** `service_name` and the instant are the caller's, and every key it
|
|
711
|
+
reads out of `_system` must EXIST — `hasOwnProperty`, not truthiness. `tenant_uuid: null`
|
|
712
|
+
therefore passes through as the explicit value it is (a webhook run proves itself by
|
|
713
|
+
signature and has no personal identity), while a MISSING `_system.tenant_id` or
|
|
714
|
+
`_system.tenant_uuid` is a reported error. A `|| null` here would make the two cases
|
|
715
|
+
identical and hide a broken envelope.
|
|
716
|
+
|
|
717
|
+
`publishWorkflowInitiated()` is **fail-safe**: it never throws. It is observability about a run
|
|
718
|
+
that has already happened, so refusing the run because the observer could not be told would
|
|
719
|
+
trade a real effect for a record of it — a lost copy is reported through the logger and the call
|
|
720
|
+
answers `false`. The one exception is a missing logger, which is the transport half's rule:
|
|
721
|
+
without it the promise "every failure is reported" cannot hold.
|
|
722
|
+
|
|
676
723
|
### redactUrl — a connection URL that is safe to log
|
|
677
724
|
|
|
678
725
|
`RABBITMQ_URL` carries the broker account, so anything that renders it verbatim
|
package/package.json
CHANGED
|
@@ -60,7 +60,8 @@ const SECTION_PREFIXES = Object.freeze({
|
|
|
60
60
|
monitoring: 'monitoring',
|
|
61
61
|
telemetry: 'telemetry',
|
|
62
62
|
alerts: 'alert',
|
|
63
|
-
registry: 'registry'
|
|
63
|
+
registry: 'registry',
|
|
64
|
+
scheduler: 'scheduler'
|
|
64
65
|
});
|
|
65
66
|
|
|
66
67
|
/**
|
|
@@ -81,7 +82,8 @@ const INFRASTRUCTURE_PREFIX_LOOKUPS = Object.freeze({
|
|
|
81
82
|
validation: 'getValidationQueueConfig',
|
|
82
83
|
monitoring: 'getMonitoringQueueConfig',
|
|
83
84
|
telemetry: 'getTelemetryQueueConfig',
|
|
84
|
-
delivery: 'getDeliveryQueueConfig'
|
|
85
|
+
delivery: 'getDeliveryQueueConfig',
|
|
86
|
+
scheduler: 'getSchedulerQueueConfig'
|
|
85
87
|
});
|
|
86
88
|
|
|
87
89
|
const INFRASTRUCTURE_PREFIXES = Object.freeze(Object.keys(INFRASTRUCTURE_PREFIX_LOOKUPS));
|
|
@@ -689,6 +691,33 @@ module.exports = {
|
|
|
689
691
|
durable: true
|
|
690
692
|
},
|
|
691
693
|
|
|
694
|
+
/**
|
|
695
|
+
* monitoring.resource - Topic exchange for resource-changed events
|
|
696
|
+
*
|
|
697
|
+
* The monitoring consumer's resource-changed watcher publishes here with a routing
|
|
698
|
+
* key of the shape `resource.changed.<resource_type>`; the delivery endpoint binds
|
|
699
|
+
* `delivery.resource.events` to it by the pattern `resource.changed.#` for WS push.
|
|
700
|
+
* Three sides of ONE wire — the publisher, this exchange and the bound queue — and
|
|
701
|
+
* until d.619 the name had an owner on none of them: a module constant on the
|
|
702
|
+
* publishing side and a required env key on the binding side, so an override or a
|
|
703
|
+
* typo on either side loses every event silently. That is the same reasoning d.270
|
|
704
|
+
* settled for the health-events fanout, and the reason the name lives here and is
|
|
705
|
+
* published by `src/index.js` § topology.
|
|
706
|
+
*
|
|
707
|
+
* `topic`, `durable`, no arguments: what the publisher asserts, and what the running
|
|
708
|
+
* broker answers for it. An exchange holds nothing to dead-letter, so — like every
|
|
709
|
+
* other exchange in this file — it carries no route, even though every QUEUE of this
|
|
710
|
+
* section routes to `monitoring.dlq`.
|
|
711
|
+
*
|
|
712
|
+
* @see api/infra/api_monitoring/src/consumer/watchers/resourceChangedPublisher.js
|
|
713
|
+
* @see api/docs/biz/70-contracts/resource-changed.md
|
|
714
|
+
*/
|
|
715
|
+
resource: {
|
|
716
|
+
type: 'exchange',
|
|
717
|
+
exchangeType: 'topic',
|
|
718
|
+
durable: true
|
|
719
|
+
},
|
|
720
|
+
|
|
692
721
|
/**
|
|
693
722
|
* monitoring.infrastructure.health.events - the monitoring consumer's own copy of
|
|
694
723
|
* the infrastructure health events, bound to the infrastructure.health.events
|
|
@@ -743,8 +772,8 @@ module.exports = {
|
|
|
743
772
|
* Telemetry queue configurations
|
|
744
773
|
*
|
|
745
774
|
* The OpenTelemetry intake of the monitoring consumer: every service publishes
|
|
746
|
-
* logs, metrics and spans to `telemetry.exchange` with a routing
|
|
747
|
-
* own name, and these three queues are bound to it
|
|
775
|
+
* logs, metrics and spans to the `telemetry.exchange` declared below, with a routing
|
|
776
|
+
* key carrying its own name, and these three queues are bound to it
|
|
748
777
|
* (`docs/standards/monitoring-queues.md`, `docs/architecture/monitoring.md`).
|
|
749
778
|
*
|
|
750
779
|
* The TTL and the cap are the ones the queues are created with today; this config is
|
|
@@ -758,6 +787,45 @@ module.exports = {
|
|
|
758
787
|
* @see api/docs/governance/confirmations/mq-consumer-contract.md 003
|
|
759
788
|
*/
|
|
760
789
|
telemetry: {
|
|
790
|
+
/**
|
|
791
|
+
* telemetry.exchange - the topic exchange every telemetry producer on the platform
|
|
792
|
+
* publishes to, and the one the three queues below are bound to.
|
|
793
|
+
*
|
|
794
|
+
* The widest wire there is: every biz service through `@onlineapps/service-wrapper`,
|
|
795
|
+
* every infrastructure service through `@onlineapps/monitoring-core` (its logger and
|
|
796
|
+
* its OpenTelemetry exporter), and the monitoring consumer, which asserts the
|
|
797
|
+
* exchange, binds the three queues and drains them — and since I16 publishes its own
|
|
798
|
+
* lines onto it as well.
|
|
799
|
+
*
|
|
800
|
+
* Until d.708 the name appeared HERE only in the prose above, while the other three
|
|
801
|
+
* platform exchanges of this file (`infrastructure.health.events`,
|
|
802
|
+
* `monitoring.workflow.fanout`, `monitoring.resource`) each had a template. So one
|
|
803
|
+
* name had three owners — a module constant in the monitoring consumer
|
|
804
|
+
* (`infra/api_monitoring/src/consumer/topology.js`) and a config default in each of
|
|
805
|
+
* monitoring-core's two publishers — which is exactly the shape d.270 gave an owner
|
|
806
|
+
* for the health-events fanout and d.619 for the resource-changed topic
|
|
807
|
+
* (`change-discipline.md` § One rail per concern).
|
|
808
|
+
*
|
|
809
|
+
* `topic`, `durable`, no arguments: what all three of those sides assert today
|
|
810
|
+
* (`assertExchange(<name>, 'topic', { durable: true })`), measured before this entry
|
|
811
|
+
* was written. The broker compares an exchange's type and durability at EVERY
|
|
812
|
+
* declaration, so a value invented here would be `406 PRECONDITION-FAILED` for
|
|
813
|
+
* whichever side booted second. An exchange holds nothing to dead-letter, so — like
|
|
814
|
+
* every other exchange in this file — it carries no route, even though every QUEUE
|
|
815
|
+
* of this section routes to `telemetry.dlq`.
|
|
816
|
+
*
|
|
817
|
+
* Declaring it is not creating it: the exchange is brought into being by the service
|
|
818
|
+
* that asserts it, the same rule as `telemetry.dlq` below.
|
|
819
|
+
*
|
|
820
|
+
* @see api/infra/api_monitoring/src/consumer/index.js — assertMonitoringTopology()
|
|
821
|
+
* @see api/shared/monitoring/monitoring-core/src/exporters/rabbitmq.js
|
|
822
|
+
*/
|
|
823
|
+
exchange: {
|
|
824
|
+
type: 'exchange',
|
|
825
|
+
exchangeType: 'topic',
|
|
826
|
+
durable: true
|
|
827
|
+
},
|
|
828
|
+
|
|
761
829
|
/** telemetry.logs.queue - OpenTelemetry log records, drained into Loki. */
|
|
762
830
|
'logs.queue': {
|
|
763
831
|
durable: true,
|
|
@@ -932,15 +1000,129 @@ module.exports = {
|
|
|
932
1000
|
},
|
|
933
1001
|
|
|
934
1002
|
/**
|
|
935
|
-
* registry.changes -
|
|
936
|
-
*
|
|
1003
|
+
* registry.changes - the fanout exchange registry index changes travel on.
|
|
1004
|
+
*
|
|
1005
|
+
* One module is every side of this wire: `@onlineapps/conn-orch-registry`,
|
|
1006
|
+
* `src/registryEventConsumer.js`. It holds the only spelling of the name (`:92`),
|
|
1007
|
+
* it is the only party that ASSERTS the exchange (`:117`,
|
|
1008
|
+
* `assertExchange(name, 'fanout', { durable: true })`), it binds each service's
|
|
1009
|
+
* `{service}.registry.events` to it with the pattern `''` (`:133`), and it publishes
|
|
1010
|
+
* the snapshot request onto it (`:331`, `routingKey: ''`). The publish path declares
|
|
1011
|
+
* nothing since d.342, so there is no second founder.
|
|
1012
|
+
*
|
|
1013
|
+
* Until d.712 this was the ONE exchange of this file with no `exchangeType`, so the
|
|
1014
|
+
* founder had to decide the kind on its own while the template only said "an
|
|
1015
|
+
* exchange, durable". `type` is one of the two properties the broker compares at
|
|
1016
|
+
* every declaration, which makes it exactly the half that cannot live somewhere
|
|
1017
|
+
* else (`change-discipline.md` § One rail per concern; the same step d.619 made for
|
|
1018
|
+
* `monitoring.resource` and d.708 for `telemetry.exchange`).
|
|
1019
|
+
*
|
|
1020
|
+
* `fanout`, `durable`, no arguments: measured on that founder, not chosen. The
|
|
1021
|
+
* binding pattern `''` only routes on a fanout, so the type is load-bearing rather
|
|
1022
|
+
* than decorative. An exchange holds nothing to dead-letter, so — like every other
|
|
1023
|
+
* exchange in this file — it carries no route.
|
|
1024
|
+
*
|
|
1025
|
+
* Declaring it is not creating it: the exchange is brought into being by the service
|
|
1026
|
+
* whose registry client subscribes to changes.
|
|
1027
|
+
*
|
|
1028
|
+
* @see api/shared/connector/conn-orch-registry/src/registryEventConsumer.js
|
|
937
1029
|
*/
|
|
938
1030
|
changes: {
|
|
939
1031
|
type: 'exchange',
|
|
1032
|
+
exchangeType: 'fanout',
|
|
940
1033
|
durable: true
|
|
941
1034
|
}
|
|
942
1035
|
},
|
|
943
1036
|
|
|
1037
|
+
/**
|
|
1038
|
+
* Scheduler queue configurations — the platform's one holder of time.
|
|
1039
|
+
*
|
|
1040
|
+
* The scheduler consumes requests for a run: a recurrence that came due, or a deferred
|
|
1041
|
+
* request somebody filed. Both are published by other services and both are the ONLY
|
|
1042
|
+
* record that the run was owed, so the family is shaped for loss-aversion rather than
|
|
1043
|
+
* for freshness (`docs/governance/confirmations/scheduler.md` 001, 004).
|
|
1044
|
+
*
|
|
1045
|
+
* @see api/infra/api_scheduler/README.md
|
|
1046
|
+
*/
|
|
1047
|
+
scheduler: {
|
|
1048
|
+
/**
|
|
1049
|
+
* scheduler.request - every request for a run, from every producer.
|
|
1050
|
+
*
|
|
1051
|
+
* NO `x-message-ttl`, deliberately, and it is the one property that differs from the
|
|
1052
|
+
* shape of a workflow queue: a request that waited out a consumer outage is still the
|
|
1053
|
+
* run that was due, and a TTL would turn that outage into runs which silently never
|
|
1054
|
+
* happened. What bounds the queue is its length, and what catches a request the
|
|
1055
|
+
* scheduler could not process is the family's dead-letter queue below — over the
|
|
1056
|
+
* DEFAULT exchange, where the routing key IS the destination name, the same way every
|
|
1057
|
+
* other infrastructure family here routes (the `dlx` exchange belongs to the business
|
|
1058
|
+
* templates, whose queues one service owns).
|
|
1059
|
+
*
|
|
1060
|
+
* Without this declaration `consume()` refuses to attach a consumer at all — a queue
|
|
1061
|
+
* with no declared route would have the broker DROP what the policy rejects
|
|
1062
|
+
* (`docs/governance/confirmations/mq-consumer-contract.md` 002).
|
|
1063
|
+
*/
|
|
1064
|
+
request: {
|
|
1065
|
+
durable: true,
|
|
1066
|
+
arguments: {
|
|
1067
|
+
'x-max-length': 10000,
|
|
1068
|
+
'x-dead-letter-exchange': '', // Default exchange: routing key IS the queue name
|
|
1069
|
+
'x-dead-letter-routing-key': 'scheduler.dlq'
|
|
1070
|
+
}
|
|
1071
|
+
},
|
|
1072
|
+
|
|
1073
|
+
/**
|
|
1074
|
+
* scheduler.workflow.events - where the scheduler learns that a run it started ENDED.
|
|
1075
|
+
*
|
|
1076
|
+
* The third queue bound to `monitoring.workflow.fanout`, beside `monitoring.workflow`
|
|
1077
|
+
* and `delivery.workflow.events`. The owner settled it in
|
|
1078
|
+
* `docs/governance/confirmations/scheduler.md` 007: the terminal event of a run
|
|
1079
|
+
* (`completed` / `failed` / `discarded`) already travels on that exchange, so the
|
|
1080
|
+
* scheduler reads it there rather than asking the observer over HTTP or reading a
|
|
1081
|
+
* database that is not its own. No producer changes; the binding is the scheduler's own
|
|
1082
|
+
* act at boot (the pattern `infra/api_delivery_endpoint/src/index.js:164` already uses),
|
|
1083
|
+
* and this file only DECLARES what that queue is.
|
|
1084
|
+
*
|
|
1085
|
+
* THE TTL IS STATED, NOT INHERITED, and it is a property of that decision rather than a
|
|
1086
|
+
* default: a fanout cannot filter, so this queue receives EVERY workflow event of the
|
|
1087
|
+
* platform and the scheduler discards most of them. 300 000 ms is what
|
|
1088
|
+
* `monitoring.workflow` carries on the same exchange - the sibling whose readings this
|
|
1089
|
+
* one follows. What an outage longer than the TTL costs is on record: those events are
|
|
1090
|
+
* lost and the run is closed by the per-schedule timeout, which 007 keeps as the
|
|
1091
|
+
* backstop rather than replacing it.
|
|
1092
|
+
*
|
|
1093
|
+
* The route goes over the DEFAULT exchange, like every other infrastructure family
|
|
1094
|
+
* here, and ends in this family's own `scheduler.dlq`. Without a declared route
|
|
1095
|
+
* `consume()` refuses to attach at all (`BaseClient.js`,
|
|
1096
|
+
* `CONSUMER_DEAD_LETTER_ROUTE_MISSING`; `mq-consumer-contract` 002).
|
|
1097
|
+
*
|
|
1098
|
+
* @see api/docs/governance/confirmations/scheduler.md 007
|
|
1099
|
+
*/
|
|
1100
|
+
'workflow.events': {
|
|
1101
|
+
durable: true,
|
|
1102
|
+
arguments: {
|
|
1103
|
+
'x-message-ttl': 300000, // Stated by confirmation 007, like monitoring.workflow
|
|
1104
|
+
'x-max-length': 10000,
|
|
1105
|
+
'x-dead-letter-exchange': '', // Default exchange: routing key IS the queue name
|
|
1106
|
+
'x-dead-letter-routing-key': 'scheduler.dlq'
|
|
1107
|
+
}
|
|
1108
|
+
},
|
|
1109
|
+
|
|
1110
|
+
/**
|
|
1111
|
+
* scheduler.dlq - the END of this family's chain.
|
|
1112
|
+
*
|
|
1113
|
+
* Durable, capped higher, no TTL and no onward route — the three properties every
|
|
1114
|
+
* dead-letter queue in this file carries, and the reason the operator's `*.dlq`
|
|
1115
|
+
* dashboard finds it (`mq-consumer-contract` 003).
|
|
1116
|
+
*/
|
|
1117
|
+
dlq: {
|
|
1118
|
+
durable: true,
|
|
1119
|
+
arguments: {
|
|
1120
|
+
// No TTL for DLQ - messages should persist
|
|
1121
|
+
'x-max-length': 50000 // Higher limit for DLQ
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
},
|
|
1125
|
+
|
|
944
1126
|
/**
|
|
945
1127
|
* The full wire name of a declared entry: the section's prefix + the entry's key.
|
|
946
1128
|
*
|
|
@@ -1219,6 +1401,23 @@ module.exports = {
|
|
|
1219
1401
|
return this.getQueueConfig('infrastructure', name);
|
|
1220
1402
|
},
|
|
1221
1403
|
|
|
1404
|
+
/**
|
|
1405
|
+
* Get scheduler queue configuration
|
|
1406
|
+
* @param {string} queueName - Queue name (e.g., 'scheduler.request')
|
|
1407
|
+
* @returns {Object} Queue configuration
|
|
1408
|
+
* @throws {ValidationError} If the name carries another prefix, or nothing declares it.
|
|
1409
|
+
*/
|
|
1410
|
+
getSchedulerQueueConfig(queueName) {
|
|
1411
|
+
if (!queueName.startsWith('scheduler.')) {
|
|
1412
|
+
throw new ValidationError(
|
|
1413
|
+
`[queueConfig] Queue ${queueName} is not a scheduler queue - Expected: a name starting with "scheduler.". `
|
|
1414
|
+
+ 'Fix: call getInfrastructureQueueConfig(), which routes each prefix to the right lookup.'
|
|
1415
|
+
);
|
|
1416
|
+
}
|
|
1417
|
+
const name = queueName.slice('scheduler.'.length);
|
|
1418
|
+
return this.getQueueConfig('scheduler', name);
|
|
1419
|
+
},
|
|
1420
|
+
|
|
1222
1421
|
/**
|
|
1223
1422
|
* Get monitoring queue configuration
|
|
1224
1423
|
* @param {string} queueName - Queue name (e.g., 'monitoring.workflow')
|
package/src/index.js
CHANGED
|
@@ -36,6 +36,10 @@ const {
|
|
|
36
36
|
publishToMonitoringServices,
|
|
37
37
|
isQueueUnavailableError,
|
|
38
38
|
} = require('./monitoring-publish');
|
|
39
|
+
const {
|
|
40
|
+
buildWorkflowInitiatedEvent,
|
|
41
|
+
publishWorkflowInitiated,
|
|
42
|
+
} = require('./monitoring-initiated');
|
|
39
43
|
const queueConfig = require('./config/queueConfig');
|
|
40
44
|
const deliveryPolicy = require('./config/deliveryPolicy');
|
|
41
45
|
const { redactUrl, UNPARSEABLE_PLACEHOLDER } = require('@onlineapps/logger-contract');
|
|
@@ -84,6 +88,11 @@ module.exports.monitoring = {
|
|
|
84
88
|
publishToMonitoringWorkflow,
|
|
85
89
|
publishToMonitoringServices,
|
|
86
90
|
isQueueUnavailableError,
|
|
91
|
+
// d.819 - the one builder of `workflow_initiated`, beside the transport half it uses
|
|
92
|
+
// (`docs/governance/confirmations/scheduler.md` 009). The gateway and the scheduler each
|
|
93
|
+
// built that event in their own words; two rails over one event is a defect by default.
|
|
94
|
+
buildWorkflowInitiatedEvent,
|
|
95
|
+
publishWorkflowInitiated,
|
|
87
96
|
};
|
|
88
97
|
/** The only public way to reach the central queue classification; `src/config/queueConfig` is an internal path, not a contract. */
|
|
89
98
|
module.exports.queueConfig = queueConfig;
|
|
@@ -109,6 +118,18 @@ module.exports.topology = Object.freeze({
|
|
|
109
118
|
monitoringInfrastructureHealthEventsQueue: queueConfig.queueName('monitoring', 'infrastructure.health.events'),
|
|
110
119
|
/** The delivery endpoint's queue bound to that exchange, for WS push. */
|
|
111
120
|
deliveryHealthEventsQueue: queueConfig.queueName('deliveryEvents', 'health.events'),
|
|
121
|
+
/**
|
|
122
|
+
* Topic exchange: the monitoring consumer's resource-changed watcher publishes here,
|
|
123
|
+
* with a routing key of the shape `resource.changed.<resource_type>`.
|
|
124
|
+
*
|
|
125
|
+
* Declared in `queueConfig` since d.619. Until then the name was a module constant on
|
|
126
|
+
* the publishing side and a required env key on the binding side
|
|
127
|
+
* (`MONITORING_RESOURCE_EXCHANGE`) — the third side of this wire with no owner, while
|
|
128
|
+
* the queue bound to it had one here since d.270.
|
|
129
|
+
*
|
|
130
|
+
* @see api/infra/api_monitoring/src/consumer/watchers/resourceChangedPublisher.js
|
|
131
|
+
*/
|
|
132
|
+
monitoringResourceExchange: queueConfig.queueName('monitoring', 'resource'),
|
|
112
133
|
/**
|
|
113
134
|
* The delivery endpoint's queue for resource-changed events, bound to the
|
|
114
135
|
* `monitoring.resource` exchange by the routing pattern `resource.changed.#`.
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The `workflow_initiated` event — ONE rail for every service that starts a run.
|
|
5
|
+
*
|
|
6
|
+
* Monitoring learns that a run exists from this event and from nothing else: its intake
|
|
7
|
+
* reads the run's tenancy, labels and trigger out of `workflow_initiated` and marks the
|
|
8
|
+
* run `active` on it (`api/infra/api_monitoring/src/consumer/index.js`). A service that
|
|
9
|
+
* does not file it starts runs that are invisible for their whole life.
|
|
10
|
+
*
|
|
11
|
+
* Until now two services built that event, each in its own words: the gateway
|
|
12
|
+
* (`infra/api_gateway/shared/monitoringInitiatedCopy.js`, with a redacted cookbook) and
|
|
13
|
+
* the scheduler (`infra/api_scheduler/src/publisher/workflowPublisher.js`, inline, the
|
|
14
|
+
* cookbook verbatim and no `tenant_uuid` at all). Two implementations of one concern are
|
|
15
|
+
* a defect by default (`change-discipline.md` § One rail per concern), and the owner
|
|
16
|
+
* settled which rail survives: this one, in the library that already owns the transport
|
|
17
|
+
* half of the same wire (`docs/governance/confirmations/scheduler.md` 009). Both copies
|
|
18
|
+
* are retired by their own services in the change that adopts this module — no period
|
|
19
|
+
* with both (`architecture-principles.md` §11).
|
|
20
|
+
*
|
|
21
|
+
* WHAT THIS MODULE DOES NOT DO, deliberately:
|
|
22
|
+
*
|
|
23
|
+
* 1. **It does not redact.** What is sensitive in a cookbook is known only to the
|
|
24
|
+
* service spec in the registry, and reading that spec would give this library a
|
|
25
|
+
* dependency on the operation specs and on `infrastructure-tools`. The caller passes
|
|
26
|
+
* the observability copy it already built (`cookbookRedaction.js` in the gateway); a
|
|
27
|
+
* path that cannot redact does not file the event at all, because an empty set of
|
|
28
|
+
* sensitive fields is silent consent to an unredacted copy.
|
|
29
|
+
* 2. **It invents no value.** `service_name` and the instant are the caller's, and every
|
|
30
|
+
* key it reads out of `_system` must EXIST — `hasOwnProperty`, not truthiness, so
|
|
31
|
+
* `tenant_uuid: null` passes through as the explicit value it is (a webhook run has
|
|
32
|
+
* no personal identity, and a scheduled run of the pilot writes `null` on purpose —
|
|
33
|
+
* `docs/governance/confirmations/run-envelope-tenant-uuid.md` 001). A `|| null` here
|
|
34
|
+
* would hide a broken envelope instead of reporting it (`architecture-principles.md`
|
|
35
|
+
* §3).
|
|
36
|
+
*
|
|
37
|
+
* @see api/docs/governance/confirmations/scheduler.md 009
|
|
38
|
+
* @see api/docs/biz/70-contracts/monitoring-events.md
|
|
39
|
+
* @see ./monitoring-publish.js — the transport half: the queue, the fanout, the fail-safe
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
const { assertLogger } = require('@onlineapps/logger-contract');
|
|
43
|
+
const { publishToMonitoringWorkflow } = require('./monitoring-publish');
|
|
44
|
+
|
|
45
|
+
/** Starting a run is not a step of the cookbook; it has its own constant coordinates. */
|
|
46
|
+
const STEP_ID = 'workflow_start';
|
|
47
|
+
const STEP_INDEX = -1;
|
|
48
|
+
/** The event's own two words, which the monitoring intake matches on. */
|
|
49
|
+
const EVENT_TYPE = 'initiated';
|
|
50
|
+
const STATUS = 'initiated';
|
|
51
|
+
|
|
52
|
+
/** The keys of `_system` this event carries, each of which must exist on the envelope. */
|
|
53
|
+
const REQUIRED_SYSTEM_KEYS = Object.freeze(['tenant_id', 'tenant_uuid']);
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The body of the `initiated` event — exactly these keys and no others.
|
|
57
|
+
*
|
|
58
|
+
* @param {Object} args
|
|
59
|
+
* @param {string} args.workflowId - the id of the run this event reports.
|
|
60
|
+
* @param {Object} args.redactedCookbook - the observability copy of the unified cookbook,
|
|
61
|
+
* ALREADY redacted by the caller; it travels as it arrived.
|
|
62
|
+
* @param {string} args.serviceName - the platform name of the service filing the event;
|
|
63
|
+
* the monitoring intake refuses an event that names no publisher (`service_name_missing`).
|
|
64
|
+
* @param {string} args.timestamp - the instant the run started, ISO-8601. The caller's,
|
|
65
|
+
* not a second reading of the clock here: the run and the row observing it then say the
|
|
66
|
+
* same thing.
|
|
67
|
+
* @returns {Object} the message for `monitoring.workflow`.
|
|
68
|
+
* @throws {Error} when an argument is missing, or the envelope does not carry what this
|
|
69
|
+
* event states about it.
|
|
70
|
+
*/
|
|
71
|
+
function buildWorkflowInitiatedEvent({ workflowId, redactedCookbook, serviceName, timestamp } = {}) {
|
|
72
|
+
if (typeof workflowId !== 'string' || workflowId.trim() === '') {
|
|
73
|
+
throw new Error(
|
|
74
|
+
'[monitoring-initiated] Missing workflow_id - Expected: the id of the run this event '
|
|
75
|
+
+ 'reports, as a non-empty string. Fix: pass workflowId of the run being published.'
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
if (typeof serviceName !== 'string' || serviceName.trim() === '') {
|
|
79
|
+
throw new Error(
|
|
80
|
+
'[monitoring-initiated] Missing service_name - Expected: the platform name of the service '
|
|
81
|
+
+ 'filing this event; the monitoring intake refuses an event that names no publisher '
|
|
82
|
+
+ '(service_name_missing). Fix: pass config.serviceName.'
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
if (typeof timestamp !== 'string' || timestamp.trim() === '') {
|
|
86
|
+
throw new Error(
|
|
87
|
+
'[monitoring-initiated] Missing timestamp - Expected: the instant the run started, as an '
|
|
88
|
+
+ 'ISO-8601 string taken from the run itself, so the run and the row observing it say the '
|
|
89
|
+
+ 'same thing. Fix: pass the envelope\'s own instant, not a new Date() read here.'
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
if (!redactedCookbook || typeof redactedCookbook !== 'object') {
|
|
93
|
+
throw new Error(
|
|
94
|
+
'[monitoring-initiated] Missing cookbook - Expected: the redacted observability copy of the '
|
|
95
|
+
+ 'unified cookbook. Fix: pass the copy the caller built with its own redaction '
|
|
96
|
+
+ '(a path that cannot redact does not file this event at all).'
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const system = redactedCookbook._system;
|
|
101
|
+
if (!system || typeof system !== 'object') {
|
|
102
|
+
throw new Error(
|
|
103
|
+
'[monitoring-initiated] Cookbook carries no _system - Expected: the unified cookbook with '
|
|
104
|
+
+ 'its system metadata, which is where the intake reads the run\'s tenancy, labels and '
|
|
105
|
+
+ 'trigger. Fix: build the copy from the unified cookbook the run was published with.'
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
for (const key of REQUIRED_SYSTEM_KEYS) {
|
|
110
|
+
if (!Object.prototype.hasOwnProperty.call(system, key)) {
|
|
111
|
+
throw new Error(
|
|
112
|
+
`[monitoring-initiated] Cookbook _system carries no ${key} - Expected: every entry path `
|
|
113
|
+
+ 'writes it explicitly (a value, or null where the path has none - a webhook run proves '
|
|
114
|
+
+ 'itself by signature, a scheduled run of the pilot has no personal identity). '
|
|
115
|
+
+ `Fix: set _system.${key} where the unified cookbook is built.`
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
event_type: EVENT_TYPE,
|
|
122
|
+
workflow_id: workflowId,
|
|
123
|
+
service_name: serviceName,
|
|
124
|
+
step_index: STEP_INDEX,
|
|
125
|
+
step_id: STEP_ID,
|
|
126
|
+
cookbook: redactedCookbook,
|
|
127
|
+
status: STATUS,
|
|
128
|
+
tenant_uuid: system.tenant_uuid,
|
|
129
|
+
tenant_id: system.tenant_id,
|
|
130
|
+
timestamp
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* File the `initiated` event. NEVER throws — a lost copy is reported, not raised.
|
|
136
|
+
*
|
|
137
|
+
* It is observability about a run that has already happened: refusing the run because the
|
|
138
|
+
* observer could not be told would trade a real effect for a record of it. Both services
|
|
139
|
+
* that build this event today made the same decision in their own words, and it moves here
|
|
140
|
+
* with the event. A missing logger is the one exception, and it is the transport half's
|
|
141
|
+
* rule (`monitoring-publish.js`): without a logger the promise "every failure is reported"
|
|
142
|
+
* cannot hold, so the call refuses instead of dropping events in silence.
|
|
143
|
+
*
|
|
144
|
+
* @param {Object} args
|
|
145
|
+
* @param {Object} args.mqClient - the connected `BaseClient`.
|
|
146
|
+
* @param {Object} args.logger - the service logger; REQUIRED.
|
|
147
|
+
* @param {string} args.workflowId
|
|
148
|
+
* @param {Object} args.redactedCookbook
|
|
149
|
+
* @param {string} args.serviceName
|
|
150
|
+
* @param {string} args.timestamp
|
|
151
|
+
* @param {Object} [args.context] - extra context for the log lines.
|
|
152
|
+
* @returns {Promise<boolean>} whether the event reached the broker.
|
|
153
|
+
*/
|
|
154
|
+
async function publishWorkflowInitiated({
|
|
155
|
+
mqClient,
|
|
156
|
+
logger,
|
|
157
|
+
workflowId,
|
|
158
|
+
redactedCookbook,
|
|
159
|
+
serviceName,
|
|
160
|
+
timestamp,
|
|
161
|
+
context = {}
|
|
162
|
+
} = {}) {
|
|
163
|
+
assertLogger(
|
|
164
|
+
'publishWorkflowInitiated',
|
|
165
|
+
logger,
|
|
166
|
+
'a copy monitoring never received is reported instead of silently returning false',
|
|
167
|
+
'pass the logger your service already built'
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
const event = buildWorkflowInitiatedEvent({ workflowId, redactedCookbook, serviceName, timestamp });
|
|
172
|
+
return await publishToMonitoringWorkflow(mqClient, event, logger, { workflowId, ...context });
|
|
173
|
+
} catch (error) {
|
|
174
|
+
logger.warn('[monitoring-initiated] Failed to publish workflow_initiated to monitoring', {
|
|
175
|
+
...context,
|
|
176
|
+
workflow_id: workflowId,
|
|
177
|
+
service_name: serviceName,
|
|
178
|
+
error: error.message
|
|
179
|
+
});
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
module.exports = {
|
|
185
|
+
buildWorkflowInitiatedEvent,
|
|
186
|
+
publishWorkflowInitiated
|
|
187
|
+
};
|
|
@@ -3437,9 +3437,11 @@ class RabbitMQClient extends EventEmitter {
|
|
|
3437
3437
|
// leaves exactly those without a route of their own, because a dead-letter
|
|
3438
3438
|
// route on a dead-letter queue is a loop or a chain with no end
|
|
3439
3439
|
// (`mq-consumer-contract` 002 point 2, 003 point 2). Until d.452 the gate
|
|
3440
|
-
// asked only the first question, so the operator's own queue
|
|
3441
|
-
//
|
|
3442
|
-
//
|
|
3440
|
+
// asked only the first question, so the operator's own queue could not be
|
|
3441
|
+
// consumed through this library at all. Who reads such a queue is that
|
|
3442
|
+
// service's own fact, not this file's —
|
|
3443
|
+
// @see api/infra/api_monitoring/src/consumer/deadLetterPeek.js
|
|
3444
|
+
// The second question is derived from the templates
|
|
3443
3445
|
// (`queueConfig.isDeadLetterTarget()`), never from a list of names, so it
|
|
3444
3446
|
// cannot fall behind them; every other queue with no route is refused
|
|
3445
3447
|
// exactly as before, with the same sentence.
|