ccqa-tools 1.37.1 → 1.38.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/README.md CHANGED
@@ -52,8 +52,9 @@ application pays nothing at all.
52
52
  | Variable | Meaning |
53
53
  | --- | --- |
54
54
  | `CCQA_COVERAGE` | `1` to enable. Any other value is a `<runId>.<specId>` and becomes the ambient spec for a process with no inbound request to read — a worker started per spec. |
55
- | `CCQA_COVERAGE_ENDPOINT` | Where to push. Unset collects in memory and reports nothing. |
56
- | `CCQA_COVERAGE_TOKEN` | Sent as a bearer token, but the current `ccqa` sink does not check it. For a relay in front of it, or a future endpoint that does. |
55
+ | `CCQA_COVERAGE_ENDPOINT` | Where to push. Defaults to `http://127.0.0.1:4757`, the loopback sink a local `ccqa run --coverage` binds; a deployed application sets its own. |
56
+ | `CCQA_COVERAGE_TOKEN` | Sent as a bearer token. The hub's coverage inbox verifies it; the loopback sink a local run binds does not check it, as before. |
57
+ | `CCQA_COVERAGE_HEADER` | One extra `name:value` header sent with every push, for a load balancer that gates the endpoint on a header. |
57
58
  | `CCQA_COVERAGE_ROOT` | Root that file ids are relative to. Defaults to `process.cwd()`. In a workspace, point it at a directory containing the sibling packages too, and give `ccqa` the same one through `coverage.projectRoot`. |
58
59
  | `CCQA_COVERAGE_INCLUDE` | Comma-separated directories to instrument, relative to the root. Defaults to `src`. |
59
60
  | `CCQA_COVERAGE_DEBUG` | `1` for diagnostics on stderr. |
@@ -26,6 +26,17 @@ function closeActorBucket(runtime, key) {
26
26
  function armGate(runtime) {
27
27
  runtime.active = runtime.buckets.size + runtime.actors.size;
28
28
  }
29
+ /**
30
+ * Sent as a bearer token. The hub's coverage inbox verifies it; the loopback
31
+ * sink a local run binds does not check it, as before — there it is carried
32
+ * for a relay in front of the sink.
33
+ */
34
+ const ENV_TOKEN = "CCQA_COVERAGE_TOKEN";
35
+ /**
36
+ * One extra `name:value` header sent with every push, for a load balancer
37
+ * that gates the endpoint on a header.
38
+ */
39
+ const ENV_HEADER = "CCQA_COVERAGE_HEADER";
29
40
  //#endregion
30
41
  //#region src/coverage/runtime-env.ts
31
42
  /**
@@ -48,6 +59,42 @@ function debugLog(config, message) {
48
59
  * that commutative, associative and idempotent, so the sink never has to know
49
60
  * how many replicas there were.
50
61
  */
62
+ /** The loopback inbox a local `ccqa run --coverage` binds — its default too. */
63
+ const DEFAULT_ENDPOINT = "http://127.0.0.1:4757";
64
+ /**
65
+ * Env → options, at register time. The endpoint defaults to the run's
66
+ * loopback inbox, so a local stack needs no endpoint configuration at all;
67
+ * only a deployed environment writes one.
68
+ */
69
+ function collectorOptionsFromEnv(env, config) {
70
+ const endpoint = env["CCQA_COVERAGE_ENDPOINT"] || "http://127.0.0.1:4757";
71
+ if (config && !env["CCQA_COVERAGE_ENDPOINT"]) debugLog(config, `endpoint defaulted to ${DEFAULT_ENDPOINT}`);
72
+ return {
73
+ endpoint,
74
+ token: env[ENV_TOKEN],
75
+ header: parseHeader(env[ENV_HEADER])
76
+ };
77
+ }
78
+ /**
79
+ * `name:value` for the one extra push header, split on the first colon so the
80
+ * value may contain more. Malformed input is warned about rather than silently
81
+ * dropped: the header exists to pass a gateway, and a push missing it fails
82
+ * looking exactly like an unreachable sink. The value may be a gateway secret,
83
+ * so the warning names the variable and never echoes its content.
84
+ */
85
+ function parseHeader(raw) {
86
+ if (!raw) return void 0;
87
+ const colon = raw.indexOf(":");
88
+ const name = colon < 0 ? "" : raw.slice(0, colon).trim();
89
+ if (name === "") {
90
+ process.stderr.write(`[ccqa-tools] ignoring ${ENV_HEADER}: expected "name:value"\n`);
91
+ return;
92
+ }
93
+ return {
94
+ name,
95
+ value: raw.slice(colon + 1).trim()
96
+ };
97
+ }
51
98
  const DEFAULT_INTERVAL_MS = 1e3;
52
99
  const DEFAULT_IDLE_TTL_MS = 12e4;
53
100
  /**
@@ -113,7 +160,7 @@ function startCollector(options, config) {
113
160
  state.droppedPushes++;
114
161
  consecutiveFailures++;
115
162
  if (config) debugLog(config, `push failed: ${String(error)}`);
116
- if (consecutiveFailures === 1 || consecutiveFailures % 10 === 0) process.stderr.write(`[ccqa-tools] push to ${options.endpoint} failed ${consecutiveFailures} times in a row: ${String(error)}\n`);
163
+ if (shouldWarnPushFailure(consecutiveFailures)) process.stderr.write(`[ccqa-tools] push to ${options.endpoint} failed ${consecutiveFailures} times in a row: ${String(error)}\n`);
117
164
  }).finally(() => {
118
165
  inFlight = false;
119
166
  });
@@ -133,6 +180,15 @@ function startCollector(options, config) {
133
180
  };
134
181
  }
135
182
  /**
183
+ * Which consecutive-failure counts warn: 1, 10, 100, then every 1000th.
184
+ * Backing off rather than a fixed every-10: the endpoint now has a default,
185
+ * so a deployed process whose endpoint was never configured fails every tick
186
+ * for its whole life — a permanent once-per-ten-seconds drone, not a burst.
187
+ */
188
+ function shouldWarnPushFailure(consecutiveFailures) {
189
+ return consecutiveFailures === 1 || consecutiveFailures === 10 || consecutiveFailures === 100 || consecutiveFailures % 1e3 === 0;
190
+ }
191
+ /**
136
192
  * `acked` is always a subset of `reached`, and neither ever shrinks, so equal
137
193
  * sizes mean nothing new — skip the copy-then-filter and check that first.
138
194
  */
@@ -213,6 +269,7 @@ function dropQuiet(entries, sent, state, cutoff, now, close) {
213
269
  async function post(options, payload) {
214
270
  const headers = { "content-type": "application/json" };
215
271
  if (options.token) headers.authorization = `Bearer ${options.token}`;
272
+ if (options.header) headers[options.header.name] = options.header.value;
216
273
  const intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS;
217
274
  const response = await fetch(options.endpoint, {
218
275
  method: "POST",
@@ -223,7 +280,11 @@ async function post(options, payload) {
223
280
  if (!response.ok) throw new Error(`sink returned ${response.status}`);
224
281
  }
225
282
  //#endregion
283
+ exports.DEFAULT_ENDPOINT = DEFAULT_ENDPOINT;
284
+ exports.collectorOptionsFromEnv = collectorOptionsFromEnv;
226
285
  exports.createCollectorState = createCollectorState;
227
286
  exports.diff = diff;
228
287
  exports.evict = evict;
288
+ exports.parseHeader = parseHeader;
289
+ exports.shouldWarnPushFailure = shouldWarnPushFailure;
229
290
  exports.startCollector = startCollector;
@@ -117,10 +117,34 @@ interface CoverageConfig {
117
117
  interface CollectorOptions {
118
118
  endpoint: string;
119
119
  token?: string | undefined;
120
+ /** One extra header on every push, for a gateway in front of the endpoint. */
121
+ header?: {
122
+ name: string;
123
+ value: string;
124
+ } | undefined;
120
125
  intervalMs?: number;
121
126
  /** Drops a spec's bucket this long after its last change and last flush. */
122
127
  idleTtlMs?: number;
123
128
  }
129
+ /** The loopback inbox a local `ccqa run --coverage` binds — its default too. */
130
+ declare const DEFAULT_ENDPOINT = "http://127.0.0.1:4757";
131
+ /**
132
+ * Env → options, at register time. The endpoint defaults to the run's
133
+ * loopback inbox, so a local stack needs no endpoint configuration at all;
134
+ * only a deployed environment writes one.
135
+ */
136
+ declare function collectorOptionsFromEnv(env: NodeJS.ProcessEnv, config?: CoverageConfig): CollectorOptions;
137
+ /**
138
+ * `name:value` for the one extra push header, split on the first colon so the
139
+ * value may contain more. Malformed input is warned about rather than silently
140
+ * dropped: the header exists to pass a gateway, and a push missing it fails
141
+ * looking exactly like an unreachable sink. The value may be a gateway secret,
142
+ * so the warning names the variable and never echoes its content.
143
+ */
144
+ declare function parseHeader(raw: string | undefined): {
145
+ name: string;
146
+ value: string;
147
+ } | undefined;
124
148
  interface CoveragePush {
125
149
  protocol: 1;
126
150
  pid: number;
@@ -171,6 +195,13 @@ interface CollectorState {
171
195
  }
172
196
  declare function createCollectorState(): CollectorState;
173
197
  declare function startCollector(options: CollectorOptions, config?: CoverageConfig): () => void;
198
+ /**
199
+ * Which consecutive-failure counts warn: 1, 10, 100, then every 1000th.
200
+ * Backing off rather than a fixed every-10: the endpoint now has a default,
201
+ * so a deployed process whose endpoint was never configured fails every tick
202
+ * for its whole life — a permanent once-per-ten-seconds drone, not a burst.
203
+ */
204
+ declare function shouldWarnPushFailure(consecutiveFailures: number): boolean;
174
205
  /** Exported for tests: the sink's HTTP round trip is the only real-I/O part. */
175
206
  declare function diff(runtime: CoverageRuntime, state: CollectorState): CoveragePush | undefined;
176
207
  /**
@@ -180,4 +211,4 @@ declare function diff(runtime: CoverageRuntime, state: CollectorState): Coverage
180
211
  */
181
212
  declare function evict(runtime: CoverageRuntime, state: CollectorState, idleTtlMs: number): void;
182
213
  //#endregion
183
- export { CollectorOptions, CollectorState, CoveragePush, createCollectorState, diff, evict, startCollector };
214
+ export { CollectorOptions, CollectorState, CoveragePush, DEFAULT_ENDPOINT, collectorOptionsFromEnv, createCollectorState, diff, evict, parseHeader, shouldWarnPushFailure, startCollector };
@@ -117,10 +117,34 @@ interface CoverageConfig {
117
117
  interface CollectorOptions {
118
118
  endpoint: string;
119
119
  token?: string | undefined;
120
+ /** One extra header on every push, for a gateway in front of the endpoint. */
121
+ header?: {
122
+ name: string;
123
+ value: string;
124
+ } | undefined;
120
125
  intervalMs?: number;
121
126
  /** Drops a spec's bucket this long after its last change and last flush. */
122
127
  idleTtlMs?: number;
123
128
  }
129
+ /** The loopback inbox a local `ccqa run --coverage` binds — its default too. */
130
+ declare const DEFAULT_ENDPOINT = "http://127.0.0.1:4757";
131
+ /**
132
+ * Env → options, at register time. The endpoint defaults to the run's
133
+ * loopback inbox, so a local stack needs no endpoint configuration at all;
134
+ * only a deployed environment writes one.
135
+ */
136
+ declare function collectorOptionsFromEnv(env: NodeJS.ProcessEnv, config?: CoverageConfig): CollectorOptions;
137
+ /**
138
+ * `name:value` for the one extra push header, split on the first colon so the
139
+ * value may contain more. Malformed input is warned about rather than silently
140
+ * dropped: the header exists to pass a gateway, and a push missing it fails
141
+ * looking exactly like an unreachable sink. The value may be a gateway secret,
142
+ * so the warning names the variable and never echoes its content.
143
+ */
144
+ declare function parseHeader(raw: string | undefined): {
145
+ name: string;
146
+ value: string;
147
+ } | undefined;
124
148
  interface CoveragePush {
125
149
  protocol: 1;
126
150
  pid: number;
@@ -171,6 +195,13 @@ interface CollectorState {
171
195
  }
172
196
  declare function createCollectorState(): CollectorState;
173
197
  declare function startCollector(options: CollectorOptions, config?: CoverageConfig): () => void;
198
+ /**
199
+ * Which consecutive-failure counts warn: 1, 10, 100, then every 1000th.
200
+ * Backing off rather than a fixed every-10: the endpoint now has a default,
201
+ * so a deployed process whose endpoint was never configured fails every tick
202
+ * for its whole life — a permanent once-per-ten-seconds drone, not a burst.
203
+ */
204
+ declare function shouldWarnPushFailure(consecutiveFailures: number): boolean;
174
205
  /** Exported for tests: the sink's HTTP round trip is the only real-I/O part. */
175
206
  declare function diff(runtime: CoverageRuntime, state: CollectorState): CoveragePush | undefined;
176
207
  /**
@@ -180,4 +211,4 @@ declare function diff(runtime: CoverageRuntime, state: CollectorState): Coverage
180
211
  */
181
212
  declare function evict(runtime: CoverageRuntime, state: CollectorState, idleTtlMs: number): void;
182
213
  //#endregion
183
- export { CollectorOptions, CollectorState, CoveragePush, createCollectorState, diff, evict, startCollector };
214
+ export { CollectorOptions, CollectorState, CoveragePush, DEFAULT_ENDPOINT, collectorOptionsFromEnv, createCollectorState, diff, evict, parseHeader, shouldWarnPushFailure, startCollector };
@@ -25,6 +25,17 @@ function closeActorBucket(runtime, key) {
25
25
  function armGate(runtime) {
26
26
  runtime.active = runtime.buckets.size + runtime.actors.size;
27
27
  }
28
+ /**
29
+ * Sent as a bearer token. The hub's coverage inbox verifies it; the loopback
30
+ * sink a local run binds does not check it, as before — there it is carried
31
+ * for a relay in front of the sink.
32
+ */
33
+ const ENV_TOKEN = "CCQA_COVERAGE_TOKEN";
34
+ /**
35
+ * One extra `name:value` header sent with every push, for a load balancer
36
+ * that gates the endpoint on a header.
37
+ */
38
+ const ENV_HEADER = "CCQA_COVERAGE_HEADER";
28
39
  //#endregion
29
40
  //#region src/coverage/runtime-env.ts
30
41
  /**
@@ -47,6 +58,42 @@ function debugLog(config, message) {
47
58
  * that commutative, associative and idempotent, so the sink never has to know
48
59
  * how many replicas there were.
49
60
  */
61
+ /** The loopback inbox a local `ccqa run --coverage` binds — its default too. */
62
+ const DEFAULT_ENDPOINT = "http://127.0.0.1:4757";
63
+ /**
64
+ * Env → options, at register time. The endpoint defaults to the run's
65
+ * loopback inbox, so a local stack needs no endpoint configuration at all;
66
+ * only a deployed environment writes one.
67
+ */
68
+ function collectorOptionsFromEnv(env, config) {
69
+ const endpoint = env["CCQA_COVERAGE_ENDPOINT"] || "http://127.0.0.1:4757";
70
+ if (config && !env["CCQA_COVERAGE_ENDPOINT"]) debugLog(config, `endpoint defaulted to ${DEFAULT_ENDPOINT}`);
71
+ return {
72
+ endpoint,
73
+ token: env[ENV_TOKEN],
74
+ header: parseHeader(env[ENV_HEADER])
75
+ };
76
+ }
77
+ /**
78
+ * `name:value` for the one extra push header, split on the first colon so the
79
+ * value may contain more. Malformed input is warned about rather than silently
80
+ * dropped: the header exists to pass a gateway, and a push missing it fails
81
+ * looking exactly like an unreachable sink. The value may be a gateway secret,
82
+ * so the warning names the variable and never echoes its content.
83
+ */
84
+ function parseHeader(raw) {
85
+ if (!raw) return void 0;
86
+ const colon = raw.indexOf(":");
87
+ const name = colon < 0 ? "" : raw.slice(0, colon).trim();
88
+ if (name === "") {
89
+ process.stderr.write(`[ccqa-tools] ignoring ${ENV_HEADER}: expected "name:value"\n`);
90
+ return;
91
+ }
92
+ return {
93
+ name,
94
+ value: raw.slice(colon + 1).trim()
95
+ };
96
+ }
50
97
  const DEFAULT_INTERVAL_MS = 1e3;
51
98
  const DEFAULT_IDLE_TTL_MS = 12e4;
52
99
  /**
@@ -112,7 +159,7 @@ function startCollector(options, config) {
112
159
  state.droppedPushes++;
113
160
  consecutiveFailures++;
114
161
  if (config) debugLog(config, `push failed: ${String(error)}`);
115
- if (consecutiveFailures === 1 || consecutiveFailures % 10 === 0) process.stderr.write(`[ccqa-tools] push to ${options.endpoint} failed ${consecutiveFailures} times in a row: ${String(error)}\n`);
162
+ if (shouldWarnPushFailure(consecutiveFailures)) process.stderr.write(`[ccqa-tools] push to ${options.endpoint} failed ${consecutiveFailures} times in a row: ${String(error)}\n`);
116
163
  }).finally(() => {
117
164
  inFlight = false;
118
165
  });
@@ -132,6 +179,15 @@ function startCollector(options, config) {
132
179
  };
133
180
  }
134
181
  /**
182
+ * Which consecutive-failure counts warn: 1, 10, 100, then every 1000th.
183
+ * Backing off rather than a fixed every-10: the endpoint now has a default,
184
+ * so a deployed process whose endpoint was never configured fails every tick
185
+ * for its whole life — a permanent once-per-ten-seconds drone, not a burst.
186
+ */
187
+ function shouldWarnPushFailure(consecutiveFailures) {
188
+ return consecutiveFailures === 1 || consecutiveFailures === 10 || consecutiveFailures === 100 || consecutiveFailures % 1e3 === 0;
189
+ }
190
+ /**
135
191
  * `acked` is always a subset of `reached`, and neither ever shrinks, so equal
136
192
  * sizes mean nothing new — skip the copy-then-filter and check that first.
137
193
  */
@@ -212,6 +268,7 @@ function dropQuiet(entries, sent, state, cutoff, now, close) {
212
268
  async function post(options, payload) {
213
269
  const headers = { "content-type": "application/json" };
214
270
  if (options.token) headers.authorization = `Bearer ${options.token}`;
271
+ if (options.header) headers[options.header.name] = options.header.value;
215
272
  const intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS;
216
273
  const response = await fetch(options.endpoint, {
217
274
  method: "POST",
@@ -222,4 +279,4 @@ async function post(options, payload) {
222
279
  if (!response.ok) throw new Error(`sink returned ${response.status}`);
223
280
  }
224
281
  //#endregion
225
- export { createCollectorState, diff, evict, startCollector };
282
+ export { DEFAULT_ENDPOINT, collectorOptionsFromEnv, createCollectorState, diff, evict, parseHeader, shouldWarnPushFailure, startCollector };
@@ -156,10 +156,17 @@ const BAGGAGE_KEY = "ccqa.coverage";
156
156
  * inbound request to read, such as a worker started per spec.
157
157
  */
158
158
  const ENV_NAME = "CCQA_COVERAGE";
159
- /** Where the collector pushes to. Unset means collect in memory only. */
160
- const ENV_ENDPOINT = "CCQA_COVERAGE_ENDPOINT";
161
- /** The current ccqa sink does not check this. Carried for a relay in front of it, or a future endpoint that does. */
159
+ /**
160
+ * Sent as a bearer token. The hub's coverage inbox verifies it; the loopback
161
+ * sink a local run binds does not check it, as before there it is carried
162
+ * for a relay in front of the sink.
163
+ */
162
164
  const ENV_TOKEN = "CCQA_COVERAGE_TOKEN";
165
+ /**
166
+ * One extra `name:value` header sent with every push, for a load balancer
167
+ * that gates the endpoint on a header.
168
+ */
169
+ const ENV_HEADER = "CCQA_COVERAGE_HEADER";
163
170
  const SPEC_ID = /^[A-Za-z0-9._\-/]{1,200}$/;
164
171
  /**
165
172
  * Accepts a carrier value only if it looks like an id we wrote.
@@ -236,6 +243,42 @@ function debugLog(config, message) {
236
243
  * that commutative, associative and idempotent, so the sink never has to know
237
244
  * how many replicas there were.
238
245
  */
246
+ /** The loopback inbox a local `ccqa run --coverage` binds — its default too. */
247
+ const DEFAULT_ENDPOINT = "http://127.0.0.1:4757";
248
+ /**
249
+ * Env → options, at register time. The endpoint defaults to the run's
250
+ * loopback inbox, so a local stack needs no endpoint configuration at all;
251
+ * only a deployed environment writes one.
252
+ */
253
+ function collectorOptionsFromEnv(env, config) {
254
+ const endpoint = env["CCQA_COVERAGE_ENDPOINT"] || "http://127.0.0.1:4757";
255
+ if (config && !env["CCQA_COVERAGE_ENDPOINT"]) debugLog(config, `endpoint defaulted to ${DEFAULT_ENDPOINT}`);
256
+ return {
257
+ endpoint,
258
+ token: env[ENV_TOKEN],
259
+ header: parseHeader(env[ENV_HEADER])
260
+ };
261
+ }
262
+ /**
263
+ * `name:value` for the one extra push header, split on the first colon so the
264
+ * value may contain more. Malformed input is warned about rather than silently
265
+ * dropped: the header exists to pass a gateway, and a push missing it fails
266
+ * looking exactly like an unreachable sink. The value may be a gateway secret,
267
+ * so the warning names the variable and never echoes its content.
268
+ */
269
+ function parseHeader(raw) {
270
+ if (!raw) return void 0;
271
+ const colon = raw.indexOf(":");
272
+ const name = colon < 0 ? "" : raw.slice(0, colon).trim();
273
+ if (name === "") {
274
+ process.stderr.write(`[ccqa-tools] ignoring ${ENV_HEADER}: expected "name:value"\n`);
275
+ return;
276
+ }
277
+ return {
278
+ name,
279
+ value: raw.slice(colon + 1).trim()
280
+ };
281
+ }
239
282
  const DEFAULT_INTERVAL_MS = 1e3;
240
283
  const DEFAULT_IDLE_TTL_MS = 12e4;
241
284
  /**
@@ -301,7 +344,7 @@ function startCollector(options, config) {
301
344
  state.droppedPushes++;
302
345
  consecutiveFailures++;
303
346
  if (config) debugLog(config, `push failed: ${String(error)}`);
304
- if (consecutiveFailures === 1 || consecutiveFailures % 10 === 0) process.stderr.write(`[ccqa-tools] push to ${options.endpoint} failed ${consecutiveFailures} times in a row: ${String(error)}\n`);
347
+ if (shouldWarnPushFailure(consecutiveFailures)) process.stderr.write(`[ccqa-tools] push to ${options.endpoint} failed ${consecutiveFailures} times in a row: ${String(error)}\n`);
305
348
  }).finally(() => {
306
349
  inFlight = false;
307
350
  });
@@ -321,6 +364,15 @@ function startCollector(options, config) {
321
364
  };
322
365
  }
323
366
  /**
367
+ * Which consecutive-failure counts warn: 1, 10, 100, then every 1000th.
368
+ * Backing off rather than a fixed every-10: the endpoint now has a default,
369
+ * so a deployed process whose endpoint was never configured fails every tick
370
+ * for its whole life — a permanent once-per-ten-seconds drone, not a burst.
371
+ */
372
+ function shouldWarnPushFailure(consecutiveFailures) {
373
+ return consecutiveFailures === 1 || consecutiveFailures === 10 || consecutiveFailures === 100 || consecutiveFailures % 1e3 === 0;
374
+ }
375
+ /**
324
376
  * `acked` is always a subset of `reached`, and neither ever shrinks, so equal
325
377
  * sizes mean nothing new — skip the copy-then-filter and check that first.
326
378
  */
@@ -401,6 +453,7 @@ function dropQuiet(entries, sent, state, cutoff, now, close) {
401
453
  async function post(options, payload) {
402
454
  const headers = { "content-type": "application/json" };
403
455
  if (options.token) headers.authorization = `Bearer ${options.token}`;
456
+ if (options.header) headers[options.header.name] = options.header.value;
404
457
  const intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS;
405
458
  const response = await fetch(options.endpoint, {
406
459
  method: "POST",
@@ -707,12 +760,7 @@ if (config.enabled) {
707
760
  files
708
761
  });
709
762
  }
710
- const endpoint = process.env[ENV_ENDPOINT];
711
- if (endpoint) startCollector({
712
- endpoint,
713
- token: process.env[ENV_TOKEN]
714
- }, config);
715
- else process.stderr.write(`[ccqa-tools] collection is enabled but ${ENV_ENDPOINT} is not set; results will be discarded\n`);
763
+ startCollector(collectorOptionsFromEnv(process.env, config), config);
716
764
  debugLog(config, `armed in pid ${process.pid}`);
717
765
  }
718
766
  /**
@@ -131,10 +131,17 @@ const BAGGAGE_KEY = "ccqa.coverage";
131
131
  * inbound request to read, such as a worker started per spec.
132
132
  */
133
133
  const ENV_NAME = "CCQA_COVERAGE";
134
- /** Where the collector pushes to. Unset means collect in memory only. */
135
- const ENV_ENDPOINT = "CCQA_COVERAGE_ENDPOINT";
136
- /** The current ccqa sink does not check this. Carried for a relay in front of it, or a future endpoint that does. */
134
+ /**
135
+ * Sent as a bearer token. The hub's coverage inbox verifies it; the loopback
136
+ * sink a local run binds does not check it, as before there it is carried
137
+ * for a relay in front of the sink.
138
+ */
137
139
  const ENV_TOKEN = "CCQA_COVERAGE_TOKEN";
140
+ /**
141
+ * One extra `name:value` header sent with every push, for a load balancer
142
+ * that gates the endpoint on a header.
143
+ */
144
+ const ENV_HEADER = "CCQA_COVERAGE_HEADER";
138
145
  const SPEC_ID = /^[A-Za-z0-9._\-/]{1,200}$/;
139
146
  /**
140
147
  * Accepts a carrier value only if it looks like an id we wrote.
@@ -211,6 +218,42 @@ function debugLog(config, message) {
211
218
  * that commutative, associative and idempotent, so the sink never has to know
212
219
  * how many replicas there were.
213
220
  */
221
+ /** The loopback inbox a local `ccqa run --coverage` binds — its default too. */
222
+ const DEFAULT_ENDPOINT = "http://127.0.0.1:4757";
223
+ /**
224
+ * Env → options, at register time. The endpoint defaults to the run's
225
+ * loopback inbox, so a local stack needs no endpoint configuration at all;
226
+ * only a deployed environment writes one.
227
+ */
228
+ function collectorOptionsFromEnv(env, config) {
229
+ const endpoint = env["CCQA_COVERAGE_ENDPOINT"] || "http://127.0.0.1:4757";
230
+ if (config && !env["CCQA_COVERAGE_ENDPOINT"]) debugLog(config, `endpoint defaulted to ${DEFAULT_ENDPOINT}`);
231
+ return {
232
+ endpoint,
233
+ token: env[ENV_TOKEN],
234
+ header: parseHeader(env[ENV_HEADER])
235
+ };
236
+ }
237
+ /**
238
+ * `name:value` for the one extra push header, split on the first colon so the
239
+ * value may contain more. Malformed input is warned about rather than silently
240
+ * dropped: the header exists to pass a gateway, and a push missing it fails
241
+ * looking exactly like an unreachable sink. The value may be a gateway secret,
242
+ * so the warning names the variable and never echoes its content.
243
+ */
244
+ function parseHeader(raw) {
245
+ if (!raw) return void 0;
246
+ const colon = raw.indexOf(":");
247
+ const name = colon < 0 ? "" : raw.slice(0, colon).trim();
248
+ if (name === "") {
249
+ process.stderr.write(`[ccqa-tools] ignoring ${ENV_HEADER}: expected "name:value"\n`);
250
+ return;
251
+ }
252
+ return {
253
+ name,
254
+ value: raw.slice(colon + 1).trim()
255
+ };
256
+ }
214
257
  const DEFAULT_INTERVAL_MS = 1e3;
215
258
  const DEFAULT_IDLE_TTL_MS = 12e4;
216
259
  /**
@@ -276,7 +319,7 @@ function startCollector(options, config) {
276
319
  state.droppedPushes++;
277
320
  consecutiveFailures++;
278
321
  if (config) debugLog(config, `push failed: ${String(error)}`);
279
- if (consecutiveFailures === 1 || consecutiveFailures % 10 === 0) process.stderr.write(`[ccqa-tools] push to ${options.endpoint} failed ${consecutiveFailures} times in a row: ${String(error)}\n`);
322
+ if (shouldWarnPushFailure(consecutiveFailures)) process.stderr.write(`[ccqa-tools] push to ${options.endpoint} failed ${consecutiveFailures} times in a row: ${String(error)}\n`);
280
323
  }).finally(() => {
281
324
  inFlight = false;
282
325
  });
@@ -296,6 +339,15 @@ function startCollector(options, config) {
296
339
  };
297
340
  }
298
341
  /**
342
+ * Which consecutive-failure counts warn: 1, 10, 100, then every 1000th.
343
+ * Backing off rather than a fixed every-10: the endpoint now has a default,
344
+ * so a deployed process whose endpoint was never configured fails every tick
345
+ * for its whole life — a permanent once-per-ten-seconds drone, not a burst.
346
+ */
347
+ function shouldWarnPushFailure(consecutiveFailures) {
348
+ return consecutiveFailures === 1 || consecutiveFailures === 10 || consecutiveFailures === 100 || consecutiveFailures % 1e3 === 0;
349
+ }
350
+ /**
299
351
  * `acked` is always a subset of `reached`, and neither ever shrinks, so equal
300
352
  * sizes mean nothing new — skip the copy-then-filter and check that first.
301
353
  */
@@ -376,6 +428,7 @@ function dropQuiet(entries, sent, state, cutoff, now, close) {
376
428
  async function post(options, payload) {
377
429
  const headers = { "content-type": "application/json" };
378
430
  if (options.token) headers.authorization = `Bearer ${options.token}`;
431
+ if (options.header) headers[options.header.name] = options.header.value;
379
432
  const intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS;
380
433
  const response = await fetch(options.endpoint, {
381
434
  method: "POST",
@@ -682,12 +735,7 @@ if (config.enabled) {
682
735
  files
683
736
  });
684
737
  }
685
- const endpoint = process.env[ENV_ENDPOINT];
686
- if (endpoint) startCollector({
687
- endpoint,
688
- token: process.env[ENV_TOKEN]
689
- }, config);
690
- else process.stderr.write(`[ccqa-tools] collection is enabled but ${ENV_ENDPOINT} is not set; results will be discarded\n`);
738
+ startCollector(collectorOptionsFromEnv(process.env, config), config);
691
739
  debugLog(config, `armed in pid ${process.pid}`);
692
740
  }
693
741
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccqa-tools",
3
- "version": "1.37.1",
3
+ "version": "1.38.0",
4
4
  "type": "module",
5
5
  "description": "Instrumentation the application under test installs, for ccqa. Currently: per-spec reach coverage.",
6
6
  "repository": {