jspurefix 5.11.1 → 5.11.3

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 (117) hide show
  1. package/README.md +13 -1
  2. package/dist/buffer/fixml/fixml-parser.js +1 -1
  3. package/dist/buffer/fixml/fixml-parser.js.map +1 -1
  4. package/dist/config/empty-log-factory.d.ts +2 -2
  5. package/dist/config/empty-log-factory.js +1 -1
  6. package/dist/config/empty-log-factory.js.map +1 -1
  7. package/dist/config/get-js-fx-logger.d.ts +2 -2
  8. package/dist/config/get-js-fx-logger.js.map +1 -1
  9. package/dist/config/js-fix-logger-factory.d.ts +2 -2
  10. package/dist/config/js-fix-logger-factory.js.map +1 -1
  11. package/dist/config/js-fix-logger.d.ts +9 -8
  12. package/dist/config/js-fix-logger.js +4 -4
  13. package/dist/config/js-fix-logger.js.map +1 -1
  14. package/dist/config/js-fix-winston-log-factory.d.ts +2 -2
  15. package/dist/config/js-fix-winston-log-factory.js +2 -2
  16. package/dist/config/js-fix-winston-log-factory.js.map +1 -1
  17. package/dist/config/winston-logger.d.ts +5 -2
  18. package/dist/config/winston-logger.js +57 -9
  19. package/dist/config/winston-logger.js.map +1 -1
  20. package/dist/dictionary/fragment-safety.d.ts +26 -0
  21. package/dist/dictionary/fragment-safety.js +154 -0
  22. package/dist/dictionary/fragment-safety.js.map +1 -0
  23. package/dist/dictionary/index.d.ts +1 -0
  24. package/dist/dictionary/index.js +1 -0
  25. package/dist/dictionary/index.js.map +1 -1
  26. package/dist/runtime/make-config.js +1 -1
  27. package/dist/runtime/make-config.js.map +1 -1
  28. package/dist/runtime/session-launcher.js +1 -1
  29. package/dist/runtime/session-launcher.js.map +1 -1
  30. package/dist/sample/http/oms/http-client.js +1 -1
  31. package/dist/sample/http/oms/http-client.js.map +1 -1
  32. package/dist/sample/http/oms/http-server.js +1 -1
  33. package/dist/sample/http/oms/http-server.js.map +1 -1
  34. package/dist/sample/tcp/qf-md/md-client.js +1 -1
  35. package/dist/sample/tcp/qf-md/md-client.js.map +1 -1
  36. package/dist/sample/tcp/qf-md/md-server.js +1 -1
  37. package/dist/sample/tcp/qf-md/md-server.js.map +1 -1
  38. package/dist/sample/tcp/recovering-skeleton/respawn-acceptor.js +1 -1
  39. package/dist/sample/tcp/recovering-skeleton/respawn-acceptor.js.map +1 -1
  40. package/dist/sample/tcp/recovering-skeleton/skeleton-client.js +1 -1
  41. package/dist/sample/tcp/recovering-skeleton/skeleton-client.js.map +1 -1
  42. package/dist/sample/tcp/recovering-skeleton/skeleton-server.js +1 -1
  43. package/dist/sample/tcp/recovering-skeleton/skeleton-server.js.map +1 -1
  44. package/dist/sample/tcp/skeleton/skeleton-session.js +1 -1
  45. package/dist/sample/tcp/skeleton/skeleton-session.js.map +1 -1
  46. package/dist/sample/tcp/trade-capture/trade-capture-client.js +1 -1
  47. package/dist/sample/tcp/trade-capture/trade-capture-client.js.map +1 -1
  48. package/dist/sample/tcp/trade-capture/trade-capture-server.js +1 -1
  49. package/dist/sample/tcp/trade-capture/trade-capture-server.js.map +1 -1
  50. package/dist/store/fix-msg-memory-store.js +1 -1
  51. package/dist/store/fix-msg-memory-store.js.map +1 -1
  52. package/dist/transport/ascii/ascii-msg-transmitter.js +1 -1
  53. package/dist/transport/ascii/ascii-msg-transmitter.js.map +1 -1
  54. package/dist/transport/ascii/ascii-session.js +1 -1
  55. package/dist/transport/ascii/ascii-session.js.map +1 -1
  56. package/dist/transport/http/http-acceptor-listener.js +1 -1
  57. package/dist/transport/http/http-acceptor-listener.js.map +1 -1
  58. package/dist/transport/http/http-acceptor.js +2 -2
  59. package/dist/transport/http/http-acceptor.js.map +1 -1
  60. package/dist/transport/http/http-initiator.js +1 -1
  61. package/dist/transport/http/http-initiator.js.map +1 -1
  62. package/dist/transport/http/http-json-sample-adapter.js +1 -1
  63. package/dist/transport/http/http-json-sample-adapter.js.map +1 -1
  64. package/dist/transport/session/fix-session.js +3 -3
  65. package/dist/transport/session/fix-session.js.map +1 -1
  66. package/dist/transport/session/session-registry.js +1 -1
  67. package/dist/transport/session/session-registry.js.map +1 -1
  68. package/dist/transport/tcp/recovering-tcp-initiator.js +1 -1
  69. package/dist/transport/tcp/recovering-tcp-initiator.js.map +1 -1
  70. package/dist/transport/tcp/tcp-acceptor-listener.js +2 -2
  71. package/dist/transport/tcp/tcp-acceptor-listener.js.map +1 -1
  72. package/dist/transport/tcp/tcp-acceptor.js +2 -2
  73. package/dist/transport/tcp/tcp-acceptor.js.map +1 -1
  74. package/dist/transport/tcp/tcp-initiator-connector.js +2 -2
  75. package/dist/transport/tcp/tcp-initiator-connector.js.map +1 -1
  76. package/dist/transport/tcp/tcp-initiator.js +1 -1
  77. package/dist/transport/tcp/tcp-initiator.js.map +1 -1
  78. package/docs/instrumentation.md +360 -0
  79. package/docs/scattered-components.md +280 -0
  80. package/jsfix.test_client.txt +62 -62
  81. package/jsfix.test_server.txt +72 -72
  82. package/package.json +1 -1
  83. package/src/buffer/fixml/fixml-parser.ts +1 -1
  84. package/src/config/empty-log-factory.ts +2 -2
  85. package/src/config/get-js-fx-logger.ts +2 -2
  86. package/src/config/js-fix-logger-factory.ts +8 -2
  87. package/src/config/js-fix-logger.ts +23 -8
  88. package/src/config/js-fix-winston-log-factory.ts +3 -3
  89. package/src/config/winston-logger.ts +93 -10
  90. package/src/dictionary/fragment-safety.ts +225 -0
  91. package/src/dictionary/index.ts +1 -0
  92. package/src/runtime/make-config.ts +1 -1
  93. package/src/runtime/session-launcher.ts +1 -1
  94. package/src/sample/http/oms/http-client.ts +1 -1
  95. package/src/sample/http/oms/http-server.ts +1 -1
  96. package/src/sample/tcp/qf-md/md-client.ts +1 -1
  97. package/src/sample/tcp/qf-md/md-server.ts +1 -1
  98. package/src/sample/tcp/recovering-skeleton/respawn-acceptor.ts +2 -1
  99. package/src/sample/tcp/recovering-skeleton/skeleton-client.ts +1 -1
  100. package/src/sample/tcp/recovering-skeleton/skeleton-server.ts +1 -1
  101. package/src/sample/tcp/skeleton/skeleton-session.ts +1 -1
  102. package/src/sample/tcp/trade-capture/trade-capture-client.ts +2 -1
  103. package/src/sample/tcp/trade-capture/trade-capture-server.ts +2 -1
  104. package/src/store/fix-msg-memory-store.ts +2 -1
  105. package/src/transport/ascii/ascii-msg-transmitter.ts +2 -1
  106. package/src/transport/ascii/ascii-session.ts +2 -1
  107. package/src/transport/http/http-acceptor-listener.ts +2 -1
  108. package/src/transport/http/http-acceptor.ts +2 -1
  109. package/src/transport/http/http-initiator.ts +2 -1
  110. package/src/transport/http/http-json-sample-adapter.ts +1 -1
  111. package/src/transport/session/fix-session.ts +2 -1
  112. package/src/transport/session/session-registry.ts +1 -1
  113. package/src/transport/tcp/recovering-tcp-initiator.ts +2 -1
  114. package/src/transport/tcp/tcp-acceptor-listener.ts +4 -2
  115. package/src/transport/tcp/tcp-acceptor.ts +2 -1
  116. package/src/transport/tcp/tcp-initiator-connector.ts +4 -2
  117. package/src/transport/tcp/tcp-initiator.ts +2 -1
@@ -0,0 +1,360 @@
1
+ # Instrumentation — design note
2
+
3
+ **Status: design, nothing implemented.** This is the shape being argued about before any
4
+ code is written, because the contract has to be right in two engines at once. Comments
5
+ and disagreement welcome on the tracking issue.
6
+
7
+ The goal is that an operator running jspurefix in production can answer, without adding
8
+ their own code: is the session up, how much is going through it, what is it costing me,
9
+ and has anything started going wrong. Today they can answer none of those except by
10
+ reading log prose.
11
+
12
+ ## What this is not
13
+
14
+ [`docs/benchmarking.md`](benchmarking.md) already measures parser throughput and
15
+ allocation. That is **offline**: `jsfix-bench` drives synthetic bytes under
16
+ `--expose-gc` so a change to a hot path can be argued about with a number.
17
+
18
+ This note is about the **live** engine — the same quantities, in a running process, at a
19
+ cost low enough to leave switched on. The two should agree on names wherever they
20
+ measure the same thing, so a bench figure and a production figure can be put side by
21
+ side.
22
+
23
+ ## Principles
24
+
25
+ 1. **The snapshot is the contract.** Prometheus, OpenTelemetry, a JSON endpoint and a
26
+ CLI table are four *renderings* of one data structure. Design that structure; the
27
+ renderings are then cheap, independent, and none of them is in the way of the others.
28
+ 2. **Zero cost when off.** A disabled sink is a no-op method call, never a string format
29
+ or an object allocation. `EmptyLogger` is the existing precedent.
30
+ 3. **Nothing new in `dependencies`.** See [packaging](#packaging-boundary) — this is the
31
+ trap most likely to be walked into.
32
+ 4. **One spec, two engines.** Metric names and semantics are defined here and
33
+ implemented identically in cspurefix. A shared dashboard is most of the value.
34
+
35
+ ## Layer 0 — the snapshot
36
+
37
+ A plain object, no dependencies, produced on demand. Everything else renders it.
38
+
39
+ There is already a rough version of this: `TcpAcceptor` logs an *acceptor census*
40
+ (`transports=1 [1@::1:57598 up 0s] sessions=0 []`). It is a stats snapshot that only
41
+ knows how to be a log line.
42
+
43
+ ```ts
44
+ interface ISessionStats {
45
+ sessionId: string // BeginString-Sender-Target
46
+ peerCompId: string
47
+ role: 'initiator' | 'acceptor'
48
+ state: SessionState // the enum, not a rendered string
49
+ upSince: Date | null
50
+ // sequence
51
+ lastSentSeqNum: number
52
+ lastPeerMsgSeqNum: number
53
+ // traffic, since process start
54
+ msgsSent: number
55
+ msgsReceived: number
56
+ bytesSent: number
57
+ bytesReceived: number
58
+ // health
59
+ resendRequestsSent: number
60
+ resendRequestsReceived: number
61
+ gapsDetected: number
62
+ reconnects: number
63
+ lastReceivedAt: Date | null
64
+ lastSentAt: Date | null
65
+ }
66
+ ```
67
+
68
+ Gauges are read from state that already exists — `FixSessionState` holds
69
+ `lastPeerMsgSeqNum`, `LastSentAt`, `lastReceivedAt`, `peerCompId`; `SessionState` is the
70
+ enum; `SessionRegistry` knows the live set. Counters are the new part.
71
+
72
+ **Open question.** Whether the snapshot is per-session with an aggregate rolled up by the
73
+ registry, or one flat structure. Per-session with rollup is more useful to a venue and
74
+ more work; a single acceptor with one counterparty gets the same answer either way.
75
+
76
+ ## Layer 1 — chokepoints
77
+
78
+ Counters are pushed through a narrow sink with a no-op default. The seams already exist;
79
+ none of these needs a new abstraction to hang off.
80
+
81
+ | what | where (jspurefix) | notes for the port |
82
+ | --- | --- | --- |
83
+ | bytes in, parse time | `AsciiParser.parseBuffer` / `parse` | the byte count is the write, not the message — a segment may hold several or half of one |
84
+ | messages decoded, by msgType | `AsciiParser.msg` | bounded label set, ~30 values |
85
+ | parse buffer high water | `ElasticBuffer.currentSize` vs `returnTo` | a buffer that keeps growing past `returnTo` is the early warning for a slow consumer |
86
+ | messages encoded, bytes out | `MsgTransmitter.send` / `settle` | `settle` already carries the payload and any error |
87
+ | session state transitions | `FixSession.setState` | transition counts, and time spent in each state |
88
+ | application vs session traffic | `AsciiSession.dispatchMsg` | the split matters: heartbeats are not throughput |
89
+ | gaps, resends, logon retries | `SessionSequenceCoordinator` | `pendingResendRequests`, `logonRetryCount` are already tracked |
90
+ | store depth, put latency | `FixMsgMemoryStore` / `FileSessionStore` | file store latency is the one that will surprise people |
91
+ | transports accepted, harvested | `TcpAcceptor` | already emits `transport` and `harvested` |
92
+ | reconnects, recovery attempts | `RecoveringTcpInitiator` | see `recovery policy` logging added in 5.11.0 |
93
+
94
+ **Bytes per second through the parser is the headline number** and deserves care: it is
95
+ what an evaluator asks for, and it is the one most easily measured wrongly. Count bytes
96
+ where they enter the parser, not where they leave the socket, and keep decode time
97
+ separate from `toObject()` — the benchmarking doc already draws that line as
98
+ `parse:view` against `parse:object`, and live instrumentation should draw it in the same
99
+ place.
100
+
101
+ ## The metric set
102
+
103
+ Names in Prometheus form; OpenTelemetry and the C# port use the same words. Prefix is
104
+ `fix_` rather than `jspurefix_` so both engines populate one dashboard.
105
+
106
+ | metric | type | labels |
107
+ | --- | --- | --- |
108
+ | `fix_session_up` | gauge (0/1) | `session`, `role` |
109
+ | `fix_session_state` | gauge (enum value) | `session` |
110
+ | `fix_messages_total` | counter | `session`, `direction`, `msg_type`, `class` (admin/app) |
111
+ | `fix_bytes_total` | counter | `session`, `direction` |
112
+ | `fix_parse_duration_seconds` | histogram | — |
113
+ | `fix_encode_duration_seconds` | histogram | — |
114
+ | `fix_seq_num` | gauge | `session`, `direction` |
115
+ | `fix_resend_requests_total` | counter | `session`, `direction` |
116
+ | `fix_gaps_detected_total` | counter | `session` |
117
+ | `fix_reconnects_total` | counter | `session` |
118
+ | `fix_store_depth` | gauge | `session` |
119
+ | `fix_transports_open` | gauge | `listener` |
120
+
121
+ Alerting mostly falls out of these: `fix_session_up == 0` during trading hours,
122
+ `rate(fix_gaps_detected_total[5m]) > 0`, a seq num that stops advancing, a store depth
123
+ that only grows.
124
+
125
+ ### Cardinality
126
+
127
+ A wildcard acceptor (`TargetCompID: '*'`) can meet an unbounded set of counterparties, so
128
+ **`session` is not automatically a safe label**. A venue with churny clients would grow
129
+ the series set without limit. Options: cap the labelled set and bucket the rest as
130
+ `other`, make per-session labelling opt-in, or expose per-session detail only through the
131
+ diagnostics endpoint and keep the scrape aggregated. Decide before shipping, not after
132
+ someone's Prometheus falls over.
133
+
134
+ `msg_type` is safe — bounded by the dictionary.
135
+
136
+ ## Structured logging
137
+
138
+ **This lands first, on its own.** Not because it is smaller — because it delivers the
139
+ whole Elastic story by itself, and because the pass that adds fields is the pass that
140
+ locates every future counter site.
141
+
142
+ Worth separating two consumers that want opposite things:
143
+
144
+ - **Filebeat and Elastic are push.** The engine writes JSON lines, Filebeat harvests,
145
+ Kibana does the drill-down. No server in the engine at all.
146
+ - **A live diagnostics endpoint is pull.** For when there is no Elastic stack, or when
147
+ someone wants the state of a process now rather than after a harvest cycle.
148
+
149
+ The click-through-and-drill-down picture is entirely the first of those. That makes the
150
+ web server in [packaging](#packaging-boundary) an independent, later, optional thing
151
+ rather than a prerequisite.
152
+
153
+ ### The leverage is bound context, not the call sites
154
+
155
+ The engine has **307 logger call sites** (239 `info`, 39 `warning`, 15 `error`, 14 `debug`)
156
+ but only **29 places a logger is constructed**. And the identity is already at those 29:
157
+
158
+ ```ts
159
+ this.sessionLogger = config.logFactory.logger(`${this.me}:${peerCompId}:FixSession`)
160
+ ```
161
+
162
+ That is the session identity, built once, and completely opaque downstream — the "regex
163
+ over English" problem, except it is not even English, it is punctuation. So the first move
164
+ is not widening 307 call sites, it is widening the 29. Bound context makes every one of the
165
+ 307 filterable without touching any of them.
166
+
167
+ Per-call fields then matter only where a line carries a *value* worth aggregating —
168
+ `msgType`, seq nums, byte counts, state transitions, gap ranges. Perhaps 25 lines, chosen
169
+ deliberately. They are the same chokepoints [layer 1](#layer-1--chokepoints) lists, which
170
+ is why doing this first leaves the metrics work half-located.
171
+
172
+ ### The interface
173
+
174
+ ```ts
175
+ export type JsFixLogFields = Record<string, unknown>
176
+
177
+ export interface IJsFixLogger {
178
+ info: (message: string, fields?: JsFixLogFields) => void
179
+ warning: (message: string, fields?: JsFixLogFields) => void
180
+ debug: (message: string, fields?: JsFixLogFields) => void
181
+ error: (e: Error, fields?: JsFixLogFields) => void
182
+ }
183
+
184
+ export abstract class JsFixLoggerFactory {
185
+ public abstract logger (type: string, context?: JsFixLogFields): IJsFixLogger
186
+ }
187
+ ```
188
+
189
+ Non-breaking, and worth being precise about why: TypeScript lets an implementation declare
190
+ *fewer* parameters than the interface, so a client's existing `info(message: string)` still
191
+ satisfies it, and their `JsFixLoggerFactory` subclass keeps working while ignoring the new
192
+ argument. Callers are unaffected either way.
193
+
194
+ `error` gains fields as well as the others. It currently takes only an `Error` — no
195
+ message, no context — and those 15 sites are the ones most wanted at 3am.
196
+
197
+ **Bound context, not a `child()` logger.** It fits the 29 construction sites, and it ports:
198
+ a `child()` would put an object lifecycle in the contract. See
199
+ [porting](#porting-to-cspurefix).
200
+
201
+ ### Not breaking the existing line
202
+
203
+ The engine has been live for years; people have grep and scrapers built on the current
204
+ output. That output must not move.
205
+
206
+ ```
207
+ 2026-08-16T14:14:56.102Z [skeleton_server:skeleton-client:FixSession] info: peer sent ...
208
+ ```
209
+
210
+ The shipped format names exactly four fields:
211
+
212
+ ```ts
213
+ printf(info => `${info.timestamp} [${info.type}] ${info.level}: ${info.message}`)
214
+ ```
215
+
216
+ So as long as context and per-call fields go into the winston info object as **separate
217
+ keys**, that printf never sees them. Verified: the same call with and without the new keys
218
+ renders byte-identically apart from the millisecond.
219
+
220
+ Three rules follow, and each has bitten already:
221
+
222
+ 1. **`type` stays verbatim at all 29 sites.** It cannot be composed from the fields — the
223
+ sites have no single rule between them (bare literals `'acceptor'`, one-part
224
+ `` `${this.me}` ``, two-part `` `${name}:TcpInitiator` ``, three-part
225
+ `` `${me}:${peer}:FixSession` ``, and one dotted `` `${type}.${t}` ``). Deriving it
226
+ means per-site rules, which is the same edit with more ways to get a string subtly
227
+ wrong. The resulting duplication between `type` and the fields is the price of not
228
+ breaking grep, and it is cheap. In a JSON rendering `type` is simply dropped, because
229
+ `component` and `app` carry it properly.
230
+ 2. **Fields are namespaced, never spread.** They travel under `context` and `fields` keys
231
+ and the *formatter* decides how to flatten. Spreading at the top level lets a caller's
232
+ `{ message: … }` clobber the real message. It also keeps the contract
233
+ transport-agnostic, which is what makes it port.
234
+ 3. **A winston format function must mutate `info`, not return a new object.** Returning a
235
+ fresh object drops `Symbol.for('level')` and winston discards the record silently — no
236
+ output, no error. Found the hard way while proving the above.
237
+
238
+ ### The default does not change
239
+
240
+ `WinstonLogger.consoleOptions()` does **not** emit JSON today — it is a `printf` rendering
241
+ prose, so "winston gives JSON for free" is not true as things stand. JSON arrives as a new
242
+ opt-in `ecsOptions()`, and the console default stays exactly as it is. Not only for this
243
+ release: changing a default log format is a major-version event at best and probably never
244
+ worth it.
245
+
246
+ `WinstonLogger.plain()` is message-only and used for FIX wire logs. That is a replay
247
+ artifact, not structured-log material, and stays as it is.
248
+
249
+ Two things make "same output after upgrade" a guarantee rather than a promise:
250
+
251
+ - **A golden-output test** rendering lines through `consoleOptions()` with and without
252
+ context and fields, asserting the strings are identical. Anyone later improving
253
+ `appFormat` then fails a test instead of breaking a customer's scraper.
254
+ - **The fields commit rewords no messages.** Mixing the two makes a before/after log diff
255
+ unreadable. Keep them separate so the diff at a given level is genuinely empty.
256
+
257
+ ### Field names
258
+
259
+ ECS names where they exist — `service.name`, `log.level`, `error.message`,
260
+ `error.stack_trace` — and everything engine-specific under `fix.*`. Context keys are plain
261
+ words; the formatter applies the prefix.
262
+
263
+ Two things the ECS format does that the console one must not, both found by reading real
264
+ output rather than by design:
265
+
266
+ - **winston's own `level` is dropped.** It would otherwise sit beside `log.level` carrying
267
+ the same value on every line. Transports filter on `Symbol.for('level')`, not the
268
+ property, so removing it changes nothing about what gets logged.
269
+ - **an error's message is the bare `Error.message`.** The console record composes
270
+ `message : stack` because that is what has always been rendered there; in JSON the stack
271
+ has `error.stack_trace` of its own, and repeating it doubles the largest line a pipeline
272
+ carries.
273
+
274
+ Neither touches how the record is built, only how the ECS format reads it — so a caller who
275
+ supplied their own format via `fileOptions` still sees exactly what they saw before.
276
+
277
+ | context key | rendered | meaning |
278
+ | --- | --- | --- |
279
+ | `component` | `fix.component` | the class, e.g. `FixSession`, `TcpAcceptor` |
280
+ | `app` | `fix.app` | application name from the session description |
281
+ | `peer` | `fix.peer` | counterparty comp id, once known |
282
+ | `role` | `fix.role` | `initiator` or `acceptor` |
283
+
284
+ This is the metric prefix decision applied to logs: `fix.msg_type` in a log line,
285
+ `fix_messages_total{msg_type}` in a metric — same words, each in its own convention's
286
+ punctuation, so one dashboard serves both engines.
287
+
288
+ `fix.session` in the canonical `BeginString-Sender-Target` form is deliberately **not** in
289
+ the table above. A wildcard acceptor does not know its peer until bind time, so the field
290
+ would be absent or wrong on exactly the sessions that need it most. It arrives with the
291
+ metrics work, where `SessionId` is properly to hand — `AsciiSession` already rebuilds its
292
+ logger at bind for this reason, so the seam is there.
293
+
294
+ Rendered, one call site gives either:
295
+
296
+ ```
297
+ 2026-08-16T14:17:31.864Z [skeleton_server:skeleton-client:FixSession] info: peer sent ResetSeqNumFlag=Y with seqNum=1, weAlsoReset=true
298
+ ```
299
+
300
+ ```json
301
+ {"@timestamp":"2026-08-16T14:17:59.278Z","log.level":"info","service.name":"jspurefix",
302
+ "message":"peer sent ResetSeqNumFlag=Y with seqNum=1, weAlsoReset=true",
303
+ "fix.component":"FixSession","fix.app":"skeleton_server","fix.peer":"skeleton-client",
304
+ "fix.reset_seq_num_flag":true,"fix.seq_num":1,"fix.we_also_reset":true}
305
+ ```
306
+
307
+ ## Packaging boundary
308
+
309
+ `express` and `axios` are **already** in `dependencies`, which is the unfinished half of
310
+ [#91](https://github.com/TimelordUK/jspurefix/issues/91): every consumer installs them
311
+ whether or not they are used. A diagnostics web server is exactly how that happens a
312
+ second time, and the fact that express is already present is what will make it tempting.
313
+
314
+ Proposed:
315
+
316
+ - **core** — snapshot, sink interface, counters. No new dependencies at all.
317
+ - **exporters** — Prometheus text format is about fifty lines and needs nothing;
318
+ OpenTelemetry needs `@opentelemetry/api` as an **optional peer** dependency.
319
+ - **diagnostics endpoint** — a separate package, or optional peer. It should also be off
320
+ by default and bind to loopback unless told otherwise: an unauthenticated page
321
+ describing your counterparties and sequence numbers is not something to open by
322
+ accident.
323
+
324
+ ## Porting to cspurefix
325
+
326
+ The contract above is deliberately language-neutral. Points where the runtimes genuinely
327
+ differ, and the spec should not pretend otherwise:
328
+
329
+ - **GC and heap.** V8 exposes collections through `perf_hooks`; the CLR has its own
330
+ counters. Do not invent a common schema — report each natively and let the dashboard
331
+ hold two panels.
332
+ - **Sink interface.** A plain interface with a no-op implementation in both; no
333
+ `EventEmitter` in the contract, since that would not port.
334
+ - **Logging shape.** .NET expresses this differently: bound context is `BeginScope`, and
335
+ per-call fields come from message templates (`"session {SessionId} up"`) rather than a
336
+ separate object. Both can emit identical JSON, but they are not the same API. The
337
+ portable contract is *bound fields plus per-call fields*, and each engine renders it
338
+ idiomatically — forcing the TypeScript shape onto `Microsoft.Extensions.Logging` would
339
+ produce something unidiomatic that nobody on that side would want to use. What must
340
+ match is the emitted field names, not the call signature.
341
+ - **Histograms.** Prometheus histograms need fixed buckets. Agree the bucket boundaries
342
+ here so latency panels are comparable between engines rather than merely similar.
343
+
344
+ ## Decisions still open
345
+
346
+ 1. Per-session snapshot with registry rollup, or one flat structure?
347
+ 2. Is `session` a metric label, and if so how is cardinality bounded?
348
+ 3. ~~Does structured logging land first, on its own?~~ **Yes** — see
349
+ [structured logging](#structured-logging). Bound context on the factory rather than a
350
+ `child()` logger, `type` kept verbatim, console format unchanged.
351
+ 4. Histogram buckets for parse and encode duration.
352
+ 5. Does the diagnostics endpoint live in this repository at all, or its own package? It
353
+ needs no framework — `node:http` and about a hundred lines — which is what stops
354
+ `express` being reached for a second time.
355
+ 6. Sampling: are parse timings measured on every message, or 1-in-N once throughput is
356
+ high enough for the measurement itself to show up in the measurement?
357
+ 7. Does the engine own the ECS field names, or hand people a formatter to remap? Someone
358
+ with an existing Splunk convention will want the latter.
359
+ 8. When per-call fields land, do the ~25 chosen lines also get their message text left
360
+ alone permanently, so the prose and the fields never drift apart?
@@ -0,0 +1,280 @@
1
+ # Scattered components — analysis and plan of attack
2
+
3
+ **Status: analysis, with phase 0 landed.** Nothing in the parser has changed. This note
4
+ sets out a generalisation of the fragmented component repair already shipped, why it is
5
+ believed tractable, what it would cost, and the order the work would land in. It is
6
+ written to port — the same shape applies to cspurefix.
7
+
8
+ There is no urgency behind it. The library has been in use for eight years and exactly one
9
+ counterparty has produced the shape that motivated the existing repair. This is the
10
+ argument being written down while it is fresh, not a queue-jumping defect.
11
+
12
+ ## Where this starts
13
+
14
+ A component is a fiction of the dictionary. Nothing goes on the wire to say one has begun
15
+ or ended, so the segment parser infers a component's extent from *position*: a tag falling
16
+ between the first and last tag attributed to a component belongs to it. Every well behaved
17
+ counterparty emits a component's tags adjacently and the inference holds.
18
+
19
+ One did not. It sent a message in which the `Instrument` tags arrived in two runs with
20
+ unrelated fields between them, and the positional model could not represent that. The fix,
21
+ ported from cspurefix, was to stop describing such a component by a range and describe it
22
+ by the actual tags and their positions — `SegmentView` in
23
+ [`src/buffer/segment/segment-view.ts`](../src/buffer/segment/segment-view.ts), built by
24
+ `TagIndex` and attached in `AsciiSegmentParser.fragments()`. Applications see no
25
+ difference; `MsgView` was already position-agnostic.
26
+
27
+ That repair is deliberately narrow. It fires only for a component at depth 1 of the
28
+ message, only when the same component is re-entered after being exited, and only once per
29
+ component name.
30
+
31
+ ## The general shape
32
+
33
+ The narrowness is the question. If `Instrument` can arrive scattered at the top level of a
34
+ message, nothing stops `SecurityXLinkage` arriving scattered *within* a scattered
35
+ `Instrument`, and nothing stops that recursing. Each level is a dictionary fiction with no
36
+ wire representation, so each level has the same freedom. Russian dolls, laid out flat.
37
+
38
+ The condition under which this is even possible is worth stating exactly, because it is
39
+ also the condition that makes it solvable:
40
+
41
+ > Contiguity is only forced where framing exists, and framing only exists where it is
42
+ > needed.
43
+
44
+ A repeating group is the sole self-describing construct in tag-value FIX — `NoXXX` plus a
45
+ fixed delimiter tag. That is the one place the wire *requires* a bounded, ordered run.
46
+ Everywhere else the layout is convention.
47
+
48
+ The complementary half is what makes the general case tractable:
49
+
50
+ > Within one level of a message, a component occurs at most once.
51
+
52
+ If a sender needs it twice there is no mechanism but a group. So tag ambiguity — *which*
53
+ `Instrument` does this tag belong to — arises only inside groups, and groups are exactly
54
+ where scattering cannot happen. **Scattering and ambiguity are disjoint.** Where position
55
+ stops being evidence, tag identity starts being sufficient.
56
+
57
+ ## The model
58
+
59
+ `SegmentDescription` presently conflates two things: identity of a node in the dictionary
60
+ tree, and a contiguous `[startPosition, endPosition]` interval. Containment is *inferred*
61
+ from the interval — `contains()`, feeding `Structure.firstContainedWithin` and
62
+ `MsgView.resolveChild`.
63
+
64
+ The generalisation: a segment owns an **ordered set of positions**, and an interval is the
65
+ compressed representation of the dense case. Containment becomes set inclusion. Stated in
66
+ one line:
67
+
68
+ > The moment position stops implying structure, the parent/child edges have to be
69
+ > materialised instead of re-derived from offsets.
70
+
71
+ `SegmentView` took the first step — positions instead of a range. It did not take the
72
+ second, which is why it cannot nest.
73
+
74
+ There is precedent in the codebase already. Group instances are not really intervals
75
+ either: `SegmentDescription.getInstance` derives an instance's bounds by carving the parent
76
+ with `delimiterPositions`. A segment whose extent is computed rather than assigned is a
77
+ shape the engine already carries.
78
+
79
+ ### Own tags
80
+
81
+ Define, for a dictionary set `S`, its **own tag set** `O(S)`: every simple tag reachable
82
+ from `S` through components *without crossing a group boundary*, plus the `NoXXX` tag of
83
+ each directly reachable group — but not the group's interior tags.
84
+
85
+ This is the distinction `flattenedTag` does not draw, and it is the crux. A group's members
86
+ are framed and are never evidence about the level above. `O(S)` is implemented as
87
+ `FragmentSafety.ownTags`.
88
+
89
+ It also subsumes a special case the current code carries. A component that wraps a single
90
+ group owns exactly `{NoXXX}` — non-empty, and locatable — so `TagIndex.isComponentGroupWrapper`
91
+ stops needing to exist.
92
+
93
+ ### The claim
94
+
95
+ ```
96
+ claim(S, U): # U = ordered position set; root: the message body
97
+ for p in U with tag(p) ∈ O(S):
98
+ if tag(p) is NoXXX for group G:
99
+ run ← maximal delimiter-framed extent from p # contiguous, guaranteed
100
+ assign run to G; for each instance i: claim(instance_i, run_i)
101
+ else:
102
+ assign p to the child of S owning tag(p)
103
+ for each component child C of S:
104
+ claim(C, positions assigned to C's subtree) # ← universe restriction
105
+ ```
106
+
107
+ Recursion is over the dictionary tree, with the universe narrowing at each step. The doll
108
+ falls out for free: `SecurityXLinkage` is claimed from `Instrument`'s claimed positions,
109
+ not from the message. Group interiors are consumed as whole runs before any component
110
+ claim sees them, so framed tags cannot leak upward.
111
+
112
+ When everything is contiguous this degenerates to what the linear parser already computes.
113
+
114
+ ## What the current code does not do
115
+
116
+ Five things, roughly in order of how likely each is to be met:
117
+
118
+ 1. **`TagIndex.getInstance` claims by `flattenedTag` across the whole message.**
119
+ `flattenedTag` is transitive through groups, so a fragmented component containing a
120
+ group would claim every member tag in the message. `isComponentGroupWrapper` guards
121
+ this, but only for single-field wrappers. Universe restriction fixes it structurally.
122
+ 2. **Detection is depth-1 only.** `exitedDepth1Components` is keyed by bare name and only
123
+ populated at `structureStack.length === 1`. Keying by stack path registers re-entry at
124
+ any depth.
125
+ 3. **Shadow fragments survive.** Repair attaches a view to one description; the other
126
+ fragments keep their original ranges and still participate in `boundLayout`, so a
127
+ nested lookup can be handed the wrong one.
128
+ 4. **`contains` is interval arithmetic.** A scattered parent's hull can swallow unrelated
129
+ siblings, so `firstContainedWithin` can answer a lookup that should be null.
130
+ 5. **The one-instance-per-name guard is right for the wrong reason.** It reads as a
131
+ limitation; it is the at-most-once property above. Worth a comment before someone
132
+ "fixes" it.
133
+
134
+ ## Blast radius
135
+
136
+ Measured, not estimated. Outside `src/buffer/segment/`, everything that *reads* an
137
+ interval:
138
+
139
+ | Site | What it does |
140
+ | --- | --- |
141
+ | `msg-view.ts` `allStrings` | already branches on `segmentView` |
142
+ | `msg-view.ts` `binarySearch` | already branches on `segmentView` |
143
+ | `msg-view.ts` `stringify` | display only |
144
+ | `segment-description.ts` `contains` | feeds `Structure` and `resolveChild` |
145
+
146
+ Everything else touching positions is a writer (`ascii-parser`, `fixml-parser`) or
147
+ diagnostics (`SegmentSummary`). Two of the four read sites already carry the sparse branch,
148
+ so this replaces an `if` rather than introducing a concept.
149
+
150
+ Unchanged: the dictionary layer, the encoder, session and transport, every generated type,
151
+ and the public `MsgView` API. The FIXML path builds structure from the XML tree and never
152
+ has the problem.
153
+
154
+ ## Plan of attack
155
+
156
+ Phasing works here because three of the four pieces have an existing oracle. Only one phase
157
+ is irreducible.
158
+
159
+ ### Phase 0 — does the assumption hold? *(landed)*
160
+
161
+ The claim attributes by tag identity, which is sound only while siblings own disjoint tags.
162
+ FIX's numbering conventions — `Underlying*`, `Leg*`, `NestedParties2/3` — exist to
163
+ guarantee that, but a house dictionary is under no obligation to honour them. So measure it
164
+ before building on it.
165
+
166
+ [`src/dictionary/fragment-safety.ts`](../src/dictionary/fragment-safety.ts) walks every
167
+ message, component and group instance body in a dictionary and reports sibling tag
168
+ collisions. It is exported, so anyone with a house dictionary can run it against their own.
169
+
170
+ The QuickFIX rendering is the one that matters. A broker onboarding pack is now almost
171
+ always a prepared QuickFIX XML rather than a reference to a spec version, so that is the
172
+ flavour the engine actually meets. Every QuickFIX dictionary shipped here is covered, plus
173
+ the repository renderings for comparison:
174
+
175
+ | Dictionary | Form | Collisions |
176
+ | --- | --- | --- |
177
+ | `qf42`, `qf43`, `qf44` | QuickFIX | 0 |
178
+ | `fix5-mod` (modified test dialect) | QuickFIX | 0 |
179
+ | `repo44`, `repo50sp2` | repository | 0 |
180
+ | `qf50sp2` | QuickFIX | **57** |
181
+
182
+ (`data/FIX44-MD.xml` is byte-identical to `data/FIX44.xml`, so it is not a separate sample.)
183
+
184
+ Every one of the 57 is the same root cause: the QuickFIX `FIX50SP2.xml` dictionary puts
185
+ `Currency(15)` inside `Instrument` *and* declares it again beside the component, in
186
+ `NewOrderSingle`, `ExecutionReport`, `Quote` and 54 other places. 14 of the 57 are inside
187
+ group instance bodies rather than at message level — `NewOrderList.ListOrdGrp.NoOrders` and
188
+ the like — which is exactly where a recursive repair would be working. The repository
189
+ rendering of the same FIX version does not do it, so this is an authoring difference rather
190
+ than a property of FIX 5.0.
191
+
192
+ Two conclusions, and the second is the uncomfortable one.
193
+
194
+ The positional parser is untroubled by any of this — whichever `15` falls within
195
+ `Instrument`'s span is `Instrument`'s. A repair attributing by tag identity cannot tell. So
196
+ `Instrument` has to be refused for repair in those sets specifically, which argues for the
197
+ collision map being consulted per set at repair time rather than being a whole-dictionary
198
+ yes/no.
199
+
200
+ And the pattern that produced it — a field declared at message level that a component
201
+ already carries — is ordinary dictionary authoring, not an exotic mistake. A broker adding
202
+ fields to a message to suit their own flow is *more* likely to write it than a spec
203
+ committee is, and the broker dialects are the ones most likely to also send the scattered
204
+ messages this whole exercise is about. On the evidence here the collision map is not a
205
+ defensive nicety at the edge of the design; it is load-bearing.
206
+
207
+ ### Phase 1 — the accessor seam
208
+
209
+ Give `SegmentDescription` `owns(pos)` and `forEachPosition(cb)`; move the four `msg-view`
210
+ sites onto them; dense implementation loops the range without allocating. Zero behaviour
211
+ change, existing suite is a complete oracle. Ships alone, and afterwards every later change
212
+ is local rather than cross-cutting.
213
+
214
+ ### Phase 2 — the index, behind the existing call site
215
+
216
+ Rebuild `TagIndex` as the recursive, universe-restricted claim above, then keep
217
+ `getInstance(name)` as a thin wrapper calling `claim(rootSet, wholeMessage)`. Same entry
218
+ point, same callers, new engine.
219
+
220
+ Not purely behaviour-preserving: universe restriction fixes the `flattenedTag` over-claim
221
+ as a side effect, so it needs its own test rather than "existing fixtures still green".
222
+
223
+ **The detail that makes this not-wasted work:** write `claim` with the `universe` parameter
224
+ and a per-node result from day one, even though the phase 2 caller only ever passes the
225
+ whole message and collapses the result. The signature carries the generality before a
226
+ caller needs it. Built instead as a straight optimisation of today's global sweep, phase 3
227
+ rewrites it.
228
+
229
+ ### Phase 3 — detection, repair at depth, containment edges
230
+
231
+ Atomic. Path-keyed detection without repair-at-depth never fires; repair-at-depth without
232
+ detection is dead code; and repair that leaves containment alone lets nested lookups pick
233
+ up siblings that merely fall inside a scattered parent's hull.
234
+
235
+ Since repair from the shallowest fragmented ancestor recursively fixes everything beneath,
236
+ an over-eager detector is wasteful rather than wrong. Bias it toward false positives.
237
+
238
+ ## Why not replace the linear parser outright
239
+
240
+ One algorithm would be cleaner than two. Three reasons against:
241
+
242
+ 1. **It is the hot path.** The linear stack pass is single-pass and allocation-light.
243
+ Claim-based parsing needs sorted indices and per-node universes.
244
+ 2. **The linear pass is what detects malformation.** A pure claim algorithm accepts almost
245
+ anything and produces a plausible object. `SegmentType.Gap` and unknown-tag handling
246
+ depend on stream order. Losing that turns protocol errors into silently wrong data,
247
+ which is strictly worse than the bug being fixed.
248
+ 3. **Repair is self-limiting.** Only flagged subtrees pay.
249
+
250
+ ## Honest limits
251
+
252
+ **Repair is exact; detection is heuristic.** Given the at-most-once property, the claim is
253
+ sound once pointed at a subtree. Deciding *which* subtrees to point it at relies on
254
+ observing re-entry in the stack machine, and at depth the machine may unwind past the
255
+ nested node before the stray tag arrives, so re-entry surfaces at whatever ancestor still
256
+ claims the tag. That part wants a test matrix rather than an argument.
257
+
258
+ **Round-tripping.** A repaired message re-encodes canonically contiguous. Byte-for-byte
259
+ replay of the original is gone for those messages; retain the raw body where that matters.
260
+
261
+ **Malformed groups are not scattering.** A broken delimiter run is corruption and must stay
262
+ a hard parse error. The two look similar on the wire and conflating them would convert real
263
+ protocol errors into wrong objects.
264
+
265
+ ## Open decisions
266
+
267
+ 1. Should a collision found by phase 0 refuse repair for the colliding pair only, or for
268
+ the whole set containing them? The `qf50sp2` result argues for the pair — refusing all
269
+ of `NewOrderSingle` over one contended tag would give up a great deal for very little.
270
+ 2. Should the collision map be computed once at dictionary load, or lazily on first repair?
271
+ 3. Should a dictionary with collisions say so at load time? An operator onboarding a broker
272
+ dialect would want to know before a live session does, and phase 0 is cheap enough to
273
+ run eagerly — but it is only meaningful to anyone who has read this note.
274
+ 4. Does a repaired segment report its hull or its true extent through `SegmentSummary`,
275
+ which is a diagnostic surface people read?
276
+ 5. Should scattering be *reported* at all — a counterparty emitting non-adjacent components
277
+ is worth knowing about even when the repair succeeds, and today it is silent.
278
+ 6. Is `stringify` on a scattered segment supposed to render wire order or claimed order?
279
+ 7. Does cspurefix take the same phase split, or land phases 1 and 2 together given its
280
+ different `MsgView`?