@camstack/system 1.2.17 → 1.2.19

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.
@@ -50,6 +50,12 @@ function formatLokiLine(entry) {
50
50
  * Group a batch into one stream per distinct label set, and sort each stream's
51
51
  * values by timestamp.
52
52
  *
53
+ * The `node` label is resolved PER ENTRY from `tags.agentId` (the emitting
54
+ * agent, falling back to `labels.node` when absent), so a batch that mixes
55
+ * hub-local lines with forwarded agent lines fans out into one stream per
56
+ * origin node — the whole point of shipping the cluster's logs through the hub
57
+ * without flattening every line onto `node="hub"`.
58
+ *
53
59
  * The sort is not cosmetic. Loki historically rejected a stream whose entries
54
60
  * were not in ascending time order, and even with out-of-order ingestion enabled
55
61
  * it is bounded by a window. Entries reach a destination from several concurrent
@@ -58,21 +64,26 @@ function formatLokiLine(entry) {
58
64
  */
59
65
  function buildPushBody(entries, labels) {
60
66
  if (entries.length === 0) return null;
61
- const base = {
62
- job: labels.job,
63
- node: labels.node
64
- };
67
+ const base = { job: labels.job };
65
68
  for (const [key, value] of Object.entries(labels.extra ?? {})) base[key] = value;
66
- const byLevel = /* @__PURE__ */ new Map();
69
+ const byStream = /* @__PURE__ */ new Map();
67
70
  for (const entry of entries) {
68
- const values = byLevel.get(entry.level) ?? [];
69
- values.push([toUnixNano(entry.timestamp), formatLokiLine(entry)]);
70
- byLevel.set(entry.level, values);
71
+ const agentId = entry.tags?.agentId;
72
+ const node = typeof agentId === "string" && agentId.length > 0 ? agentId : labels.node;
73
+ const key = `${node}\u0000${entry.level}`;
74
+ const bucket = byStream.get(key) ?? {
75
+ node,
76
+ level: entry.level,
77
+ values: []
78
+ };
79
+ bucket.values.push([toUnixNano(entry.timestamp), formatLokiLine(entry)]);
80
+ byStream.set(key, bucket);
71
81
  }
72
82
  const streams = [];
73
- for (const [level, values] of byLevel) streams.push({
83
+ for (const { node, level, values } of byStream.values()) streams.push({
74
84
  stream: {
75
85
  ...base,
86
+ node,
76
87
  level
77
88
  },
78
89
  values: values.toSorted((a, b) => a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)
@@ -118,10 +129,32 @@ var LokiDestination = class {
118
129
  }
119
130
  async initialize(config) {
120
131
  this.config = config ?? null;
121
- if (!this.isActive()) return;
132
+ this.startTimerIfActive();
133
+ }
134
+ /**
135
+ * Re-apply configuration to an already-initialized destination after a
136
+ * settings change, without dropping the queued tail. Stops the current flush
137
+ * timer, swaps the config in, and starts a fresh timer only when the new
138
+ * config is active. This is what makes the addon's "enable without a restart"
139
+ * contract real: `initialize()` starts a timer, so calling it twice would leak
140
+ * the first one — `reconfigure()` clears it first.
141
+ */
142
+ async reconfigure(config) {
143
+ this.stopTimer();
144
+ this.config = config;
145
+ this.startTimerIfActive();
146
+ }
147
+ stopTimer() {
148
+ if (this.timer !== void 0) {
149
+ this.clearIntervalFn(this.timer);
150
+ this.timer = void 0;
151
+ }
152
+ }
153
+ startTimerIfActive() {
154
+ if (!this.isActive() || this.config === null) return;
122
155
  this.timer = this.setIntervalFn(() => {
123
156
  this.flush();
124
- }, Math.max(250, config?.batchIntervalMs ?? 5e3));
157
+ }, Math.max(250, this.config.batchIntervalMs));
125
158
  const timer = this.timer;
126
159
  if (typeof timer === "object" && timer !== null && "unref" in timer) timer.unref();
127
160
  }
@@ -254,10 +287,7 @@ var LokiDestination = class {
254
287
  return [];
255
288
  }
256
289
  async shutdown() {
257
- if (this.timer !== void 0) {
258
- this.clearIntervalFn(this.timer);
259
- this.timer = void 0;
260
- }
290
+ this.stopTimer();
261
291
  await this.flush();
262
292
  this.config = null;
263
293
  }
@@ -296,6 +326,17 @@ var LokiLoggingAddon = class extends require_dist.BaseAddon {
296
326
  this.destination = null;
297
327
  }
298
328
  /**
329
+ * React to a settings change (enable/disable, URL, level, batching) without a
330
+ * restart — the contract this addon advertises. `BaseAddon` has already
331
+ * reloaded `this.config` by the time this runs; re-apply it to the live
332
+ * destination so toggling `enabled` on actually starts pushing (and off stops
333
+ * it). Without this the destination keeps its boot-time config forever.
334
+ */
335
+ async onConfigChanged() {
336
+ await this.destination?.reconfigure(this.destinationConfig());
337
+ this.ctx.logger.info(this.config.enabled && this.config.url.length > 0 ? `Loki logging reconfigured → ${this.config.url}` : "Loki logging now inactive (disabled or no URL)");
338
+ }
339
+ /**
299
340
  * `node` is a Loki LABEL, so it must be the node's real identity and must not
300
341
  * be operator-editable — a typo would fork the stream and split a node's history
301
342
  * in two. `kernel.localNodeId` is the Moleculer node id (`'hub'` on the hub, the
@@ -45,6 +45,12 @@ function formatLokiLine(entry) {
45
45
  * Group a batch into one stream per distinct label set, and sort each stream's
46
46
  * values by timestamp.
47
47
  *
48
+ * The `node` label is resolved PER ENTRY from `tags.agentId` (the emitting
49
+ * agent, falling back to `labels.node` when absent), so a batch that mixes
50
+ * hub-local lines with forwarded agent lines fans out into one stream per
51
+ * origin node — the whole point of shipping the cluster's logs through the hub
52
+ * without flattening every line onto `node="hub"`.
53
+ *
48
54
  * The sort is not cosmetic. Loki historically rejected a stream whose entries
49
55
  * were not in ascending time order, and even with out-of-order ingestion enabled
50
56
  * it is bounded by a window. Entries reach a destination from several concurrent
@@ -53,21 +59,26 @@ function formatLokiLine(entry) {
53
59
  */
54
60
  function buildPushBody(entries, labels) {
55
61
  if (entries.length === 0) return null;
56
- const base = {
57
- job: labels.job,
58
- node: labels.node
59
- };
62
+ const base = { job: labels.job };
60
63
  for (const [key, value] of Object.entries(labels.extra ?? {})) base[key] = value;
61
- const byLevel = /* @__PURE__ */ new Map();
64
+ const byStream = /* @__PURE__ */ new Map();
62
65
  for (const entry of entries) {
63
- const values = byLevel.get(entry.level) ?? [];
64
- values.push([toUnixNano(entry.timestamp), formatLokiLine(entry)]);
65
- byLevel.set(entry.level, values);
66
+ const agentId = entry.tags?.agentId;
67
+ const node = typeof agentId === "string" && agentId.length > 0 ? agentId : labels.node;
68
+ const key = `${node}\u0000${entry.level}`;
69
+ const bucket = byStream.get(key) ?? {
70
+ node,
71
+ level: entry.level,
72
+ values: []
73
+ };
74
+ bucket.values.push([toUnixNano(entry.timestamp), formatLokiLine(entry)]);
75
+ byStream.set(key, bucket);
66
76
  }
67
77
  const streams = [];
68
- for (const [level, values] of byLevel) streams.push({
78
+ for (const { node, level, values } of byStream.values()) streams.push({
69
79
  stream: {
70
80
  ...base,
81
+ node,
71
82
  level
72
83
  },
73
84
  values: values.toSorted((a, b) => a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)
@@ -113,10 +124,32 @@ var LokiDestination = class {
113
124
  }
114
125
  async initialize(config) {
115
126
  this.config = config ?? null;
116
- if (!this.isActive()) return;
127
+ this.startTimerIfActive();
128
+ }
129
+ /**
130
+ * Re-apply configuration to an already-initialized destination after a
131
+ * settings change, without dropping the queued tail. Stops the current flush
132
+ * timer, swaps the config in, and starts a fresh timer only when the new
133
+ * config is active. This is what makes the addon's "enable without a restart"
134
+ * contract real: `initialize()` starts a timer, so calling it twice would leak
135
+ * the first one — `reconfigure()` clears it first.
136
+ */
137
+ async reconfigure(config) {
138
+ this.stopTimer();
139
+ this.config = config;
140
+ this.startTimerIfActive();
141
+ }
142
+ stopTimer() {
143
+ if (this.timer !== void 0) {
144
+ this.clearIntervalFn(this.timer);
145
+ this.timer = void 0;
146
+ }
147
+ }
148
+ startTimerIfActive() {
149
+ if (!this.isActive() || this.config === null) return;
117
150
  this.timer = this.setIntervalFn(() => {
118
151
  this.flush();
119
- }, Math.max(250, config?.batchIntervalMs ?? 5e3));
152
+ }, Math.max(250, this.config.batchIntervalMs));
120
153
  const timer = this.timer;
121
154
  if (typeof timer === "object" && timer !== null && "unref" in timer) timer.unref();
122
155
  }
@@ -249,10 +282,7 @@ var LokiDestination = class {
249
282
  return [];
250
283
  }
251
284
  async shutdown() {
252
- if (this.timer !== void 0) {
253
- this.clearIntervalFn(this.timer);
254
- this.timer = void 0;
255
- }
285
+ this.stopTimer();
256
286
  await this.flush();
257
287
  this.config = null;
258
288
  }
@@ -291,6 +321,17 @@ var LokiLoggingAddon = class extends BaseAddon {
291
321
  this.destination = null;
292
322
  }
293
323
  /**
324
+ * React to a settings change (enable/disable, URL, level, batching) without a
325
+ * restart — the contract this addon advertises. `BaseAddon` has already
326
+ * reloaded `this.config` by the time this runs; re-apply it to the live
327
+ * destination so toggling `enabled` on actually starts pushing (and off stops
328
+ * it). Without this the destination keeps its boot-time config forever.
329
+ */
330
+ async onConfigChanged() {
331
+ await this.destination?.reconfigure(this.destinationConfig());
332
+ this.ctx.logger.info(this.config.enabled && this.config.url.length > 0 ? `Loki logging reconfigured → ${this.config.url}` : "Loki logging now inactive (disabled or no URL)");
333
+ }
334
+ /**
294
335
  * `node` is a Loki LABEL, so it must be the node's real identity and must not
295
336
  * be operator-editable — a typo would fork the stream and split a node's history
296
337
  * in two. `kernel.localNodeId` is the Moleculer node id (`'hub'` on the hub, the
@@ -60,6 +60,17 @@ export declare class LokiDestination implements ILogDestination {
60
60
  private readonly clearIntervalFn;
61
61
  constructor(deps?: LokiDestinationDeps);
62
62
  initialize(config?: LokiConfig): Promise<void>;
63
+ /**
64
+ * Re-apply configuration to an already-initialized destination after a
65
+ * settings change, without dropping the queued tail. Stops the current flush
66
+ * timer, swaps the config in, and starts a fresh timer only when the new
67
+ * config is active. This is what makes the addon's "enable without a restart"
68
+ * contract real: `initialize()` starts a timer, so calling it twice would leak
69
+ * the first one — `reconfigure()` clears it first.
70
+ */
71
+ reconfigure(config: LokiConfig): Promise<void>;
72
+ private stopTimer;
73
+ private startTimerIfActive;
63
74
  /** Enabled AND pointed somewhere. An enabled destination with no URL is inert. */
64
75
  private isActive;
65
76
  write(entry: LogEntry): void;
@@ -18,6 +18,14 @@ export declare class LokiLoggingAddon extends BaseAddon<LokiAddonConfig> {
18
18
  constructor();
19
19
  protected onInitialize(): Promise<ProviderRegistration[]>;
20
20
  protected onShutdown(): Promise<void>;
21
+ /**
22
+ * React to a settings change (enable/disable, URL, level, batching) without a
23
+ * restart — the contract this addon advertises. `BaseAddon` has already
24
+ * reloaded `this.config` by the time this runs; re-apply it to the live
25
+ * destination so toggling `enabled` on actually starts pushing (and off stops
26
+ * it). Without this the destination keeps its boot-time config forever.
27
+ */
28
+ protected onConfigChanged(): Promise<void>;
21
29
  /**
22
30
  * `node` is a Loki LABEL, so it must be the node's real identity and must not
23
31
  * be operator-editable — a typo would fork the stream and split a node's history
@@ -11,7 +11,12 @@ export interface LokiPushBody {
11
11
  export interface LokiLabelInput {
12
12
  /** Fixed `job` label. Bounded by configuration, never per-entry. */
13
13
  readonly job: string;
14
- /** This node's id. Bounded by cluster size. */
14
+ /**
15
+ * Fallback node id for entries that carry no `tags.agentId` origin — the
16
+ * id of the node running this destination. The per-entry origin
17
+ * (`tags.agentId`) is preferred so forwarded agent logs are attributed to
18
+ * their source node rather than the hub that ships them.
19
+ */
15
20
  readonly node: string;
16
21
  /** Extra static labels from configuration. Values must be low-cardinality. */
17
22
  readonly extra?: Readonly<Record<string, string>>;
@@ -37,6 +42,12 @@ export declare function formatLokiLine(entry: LogEntry): string;
37
42
  * Group a batch into one stream per distinct label set, and sort each stream's
38
43
  * values by timestamp.
39
44
  *
45
+ * The `node` label is resolved PER ENTRY from `tags.agentId` (the emitting
46
+ * agent, falling back to `labels.node` when absent), so a batch that mixes
47
+ * hub-local lines with forwarded agent lines fans out into one stream per
48
+ * origin node — the whole point of shipping the cluster's logs through the hub
49
+ * without flattening every line onto `node="hub"`.
50
+ *
40
51
  * The sort is not cosmetic. Loki historically rejected a stream whose entries
41
52
  * were not in ascending time order, and even with out-of-order ingestion enabled
42
53
  * it is bounded by a window. Entries reach a destination from several concurrent
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/system",
3
- "version": "1.2.17",
3
+ "version": "1.2.19",
4
4
  "description": "Core addon for CamStack — builtins, pipeline, process management, auth, logging, events",
5
5
  "keywords": [
6
6
  "camstack",