catalyst-core 1.1.0 → 1.2.0-canary.1

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/dist/otel.js CHANGED
@@ -1,27 +1,448 @@
1
- import { metrics } from "@opentelemetry/api"
2
1
  import { NodeSDK } from "@opentelemetry/sdk-node"
3
2
  import { resourceFromAttributes } from "@opentelemetry/resources"
4
- import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-node"
3
+ import {
4
+ metrics,
5
+ trace,
6
+ TraceFlags,
7
+ SpanStatusCode,
8
+ context,
9
+ createContextKey,
10
+ isSpanContextValid,
11
+ } from "@opentelemetry/api"
12
+ import {
13
+ BatchSpanProcessor,
14
+ TraceIdRatioBasedSampler,
15
+ ParentBasedSampler,
16
+ SamplingDecision,
17
+ } from "@opentelemetry/sdk-trace-node"
18
+ import { setGlobalErrorHandler } from "@opentelemetry/core"
5
19
  import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics"
6
20
  import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-grpc"
7
21
  import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-grpc"
8
22
  import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node"
9
- import { TraceIdRatioBasedSampler, ParentBasedSampler } from "@opentelemetry/sdk-trace-node"
10
23
  import { OTLPTraceExporter as OTLPTraceExporterHTTP } from "@opentelemetry/exporter-trace-otlp-http"
11
24
  import { OTLPMetricExporter as OTLPMetricExporterHTTP } from "@opentelemetry/exporter-metrics-otlp-http"
12
- import { trace, context, createContextKey } from "@opentelemetry/api"
25
+ import fs from "fs"
26
+ import os from "os"
13
27
 
14
28
  export const IS_BOT_KEY = createContextKey("catalyst.is_bot")
15
29
 
16
30
  import semanticConventions from "@opentelemetry/semantic-conventions"
17
31
  const { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION, ATTR_DEPLOYMENT_ENVIRONMENT } = semanticConventions
18
32
 
19
- // The app-wide logger only exists once the app calls configureLogger() from
20
- // catalyst-core/logger, which nothing in core does for it. Resolve it per call,
21
- // so a logger configured after this module loads is still picked up, and fall
22
- // back to console so init() never throws a ReferenceError on a bare `logger`.
23
- const getLogger = () => global.logger ?? console
33
+ const formatMB = (bytes) => (bytes / 1024 / 1024).toFixed(1)
34
+
35
+ function getLogger() {
36
+ const l =
37
+ (typeof logger !== "undefined" && logger) ||
38
+ (typeof global !== "undefined" && global.logger) ||
39
+ console
40
+ return {
41
+ info: (msg, ...meta) => (l.info ? l.info(msg, ...meta) : console.log(msg, ...meta)),
42
+ warn: (msg, ...meta) =>
43
+ l.warn ? l.warn(msg, ...meta) : l.info ? l.info(msg, ...meta) : console.warn(msg, ...meta),
44
+ error: (msg, ...meta) => (l.error ? l.error(msg, ...meta) : console.error(msg, ...meta)),
45
+ debug: (msg, ...meta) => (l.debug ? l.debug(msg, ...meta) : console.debug(msg, ...meta)),
46
+ }
47
+ }
48
+
49
+ // undefined if the file is absent, or (for a limit) unbounded.
50
+ function readCgroupBytesFile(path) {
51
+ try {
52
+ const raw = fs.readFileSync(path, "utf8").trim()
53
+ const bytes = raw === "max" ? NaN : Number(raw)
54
+ return Number.isFinite(bytes) && bytes > 0 && bytes < Number.MAX_SAFE_INTEGER ? bytes : undefined
55
+ } catch {
56
+ return undefined
57
+ }
58
+ }
59
+
60
+ // os.totalmem() reports the node's memory, not the pod's limit — read the
61
+ // cgroup directly instead. Cached: fixed for the process's lifetime.
62
+ let memoryLimitBytes
63
+ function getMemoryLimitBytes() {
64
+ return (memoryLimitBytes ??=
65
+ readCgroupBytesFile("/sys/fs/cgroup/memory.max") ??
66
+ readCgroupBytesFile("/sys/fs/cgroup/memory/memory.limit_in_bytes") ??
67
+ os.totalmem())
68
+ }
69
+
70
+ // Whole-cgroup usage, not just this process's RSS — differs if anything else
71
+ // shares the container. Changes constantly, so never cached (unlike the limit).
72
+ function getCgroupCurrentBytes() {
73
+ return (
74
+ readCgroupBytesFile("/sys/fs/cgroup/memory.current") ??
75
+ readCgroupBytesFile("/sys/fs/cgroup/memory/memory.usage_in_bytes")
76
+ )
77
+ }
78
+
79
+ // 85% of the memory limit — leaves headroom before an OOM kill.
80
+ function highMemoryRssMB() {
81
+ return Math.round((getMemoryLimitBytes() * 0.85) / 1024 / 1024)
82
+ }
83
+
84
+ // 0.4 = 50% of the limit as heap budget (common container guidance) × 80% warning point.
85
+ function highMemoryHeapUsedMB() {
86
+ return Math.round((getMemoryLimitBytes() * 0.4) / 1024 / 1024)
87
+ }
88
+
89
+ let lastHighMemoryAlertTime = 0
90
+ function checkHighMemoryAlert(mem = process.memoryUsage(), contextStr = "", thresholds = {}) {
91
+ // Overrides are mainly for testing (e.g. forcing a low staging threshold).
92
+ // Validated so a bad value falls back instead of misfiring every check.
93
+ const isValidThreshold = (v) => typeof v === "number" && v > 0
94
+ const rssLimitMB = isValidThreshold(thresholds.rssMB) ? thresholds.rssMB : highMemoryRssMB()
95
+ const heapLimitMB = isValidThreshold(thresholds.heapUsedMB)
96
+ ? thresholds.heapUsedMB
97
+ : highMemoryHeapUsedMB()
98
+
99
+ const now = Date.now()
100
+ // Throttle alert to at most once per 60 seconds
101
+ if (now - lastHighMemoryAlertTime < 60000) return
102
+
103
+ const rssMB = mem.rss / 1024 / 1024
104
+ const heapUsedMB = mem.heapUsed / 1024 / 1024
105
+
106
+ if (rssMB > rssLimitMB || heapUsedMB > heapLimitMB) {
107
+ lastHighMemoryAlertTime = now
108
+ const ratio = (mem.rss / (mem.heapUsed || 1)).toFixed(2)
109
+ getLogger().warn(
110
+ `⚠️ [OTEL High Memory Warning] RSS=${formatMB(mem.rss)}MB, HeapUsed=${formatMB(mem.heapUsed)}MB, HeapTotal=${formatMB(mem.heapTotal)}MB, External=${formatMB(mem.external)}MB (RSS/Heap ratio: ${ratio}) ${contextStr ? `| Context: ${contextStr}` : ""}`
111
+ )
112
+ }
113
+ }
114
+
115
+ // Bit-range sampling decision helper
116
+ function getDecisionForBits(traceId, start, end, rate) {
117
+ if (rate >= 1.0) return true
118
+ if (rate <= 0.0) return false
119
+ if (!traceId || traceId.length < end) return false
120
+ const hexPart = traceId.substring(start, end)
121
+ const value = parseInt(hexPart, 16)
122
+ const maxValue = Math.pow(16, end - start) - 1
123
+ return value / maxValue < rate
124
+ }
125
+
126
+ // Custom Status Aware Sampler
127
+ class StatusAwareSampler {
128
+ constructor(samplingRate) {
129
+ this.samplingRate = samplingRate
130
+ }
131
+
132
+ shouldSample(context, traceId) {
133
+ const parentSpanContext = trace.getSpanContext(context)
134
+
135
+ if (parentSpanContext && isSpanContextValid(parentSpanContext)) {
136
+ if (parentSpanContext.traceFlags & TraceFlags.SAMPLED) {
137
+ return { decision: SamplingDecision.RECORD_AND_SAMPLED }
138
+ } else {
139
+ return { decision: SamplingDecision.RECORD }
140
+ }
141
+ }
142
+
143
+ // Head sampling using characters 0-12
144
+ const isHeadSampled = getDecisionForBits(traceId, 0, 12, this.samplingRate)
145
+ if (isHeadSampled) {
146
+ return { decision: SamplingDecision.RECORD_AND_SAMPLED }
147
+ } else {
148
+ return { decision: SamplingDecision.RECORD }
149
+ }
150
+ }
151
+
152
+ toString() {
153
+ return `StatusAwareSampler{samplingRate=${this.samplingRate}}`
154
+ }
155
+ }
156
+
157
+ // Custom Span Processor for Status Aware Error Sampling
158
+ class PromotingSpanProcessor {
159
+ constructor(exporter, config) {
160
+ this.exporter = exporter
161
+ this.config = config
162
+ this.buffer = new Map()
163
+ this.promotedTraces = new Map()
164
+
165
+ // Delegating BatchSpanProcessor for head-sampled spans
166
+ this.batchProcessor = new BatchSpanProcessor(exporter, config.batchProcessorConfig)
167
+
168
+ this.cleanupInterval = setInterval(() => {
169
+ this._cleanupBuffers()
170
+ }, 30000)
171
+
172
+ if (this.cleanupInterval.unref) {
173
+ this.cleanupInterval.unref()
174
+ }
175
+ }
176
+
177
+ onStart(span, parentContext) {
178
+ const isSampled = (span.spanContext().traceFlags & TraceFlags.SAMPLED) !== 0
179
+ if (isSampled) {
180
+ this.batchProcessor.onStart(span, parentContext)
181
+ }
182
+ }
183
+
184
+ onEnd(span) {
185
+ const traceId = span.spanContext().traceId
186
+ const isSampled = (span.spanContext().traceFlags & TraceFlags.SAMPLED) !== 0
187
+
188
+ if (this.promotedTraces.has(traceId)) {
189
+ const promotionRate = this.promotedTraces.get(traceId).promotionRate
190
+ this._exportSpanImmediately(span, promotionRate)
191
+ return
192
+ }
193
+
194
+ if (isSampled) {
195
+ this.batchProcessor.onEnd(span)
196
+ return
197
+ }
198
+
199
+ // Buffer the RECORD_ONLY span
200
+ let record = this.buffer.get(traceId)
201
+ if (!record) {
202
+ record = {
203
+ spans: [],
204
+ rootSpan: null,
205
+ hasChildError: false,
206
+ timestamp: Date.now(),
207
+ }
208
+ this.buffer.set(traceId, record)
209
+ }
210
+
211
+ const isRoot = !span.parentSpanId
212
+ if (isRoot) {
213
+ record.rootSpan = span
214
+ } else {
215
+ record.spans.push(span)
216
+ }
217
+
218
+ if (!isRoot && span.status && span.status.code === SpanStatusCode.ERROR) {
219
+ record.hasChildError = true
220
+ }
221
+
222
+ if (isRoot) {
223
+ this._evaluateAndProcessPromotion(traceId, record)
224
+ }
225
+ }
226
+
227
+ _evaluateAndProcessPromotion(traceId, record) {
228
+ const rootSpan = record.rootSpan
229
+ if (!rootSpan) return
230
+
231
+ const isBot = rootSpan.attributes["http.response.is_bot"] === true
232
+ if (isBot && !this.config.promoteBotTraffic) {
233
+ this.buffer.delete(traceId)
234
+ return
235
+ }
236
+
237
+ const statusCode = Number(
238
+ rootSpan.attributes["http.status_code"] || rootSpan.attributes["http.response.status_code"] || 0
239
+ )
240
+
241
+ const is5xx = statusCode >= 500 && statusCode < 600
242
+ const is4xx = statusCode >= 400 && statusCode < 500
243
+ const isRootError = rootSpan.status && rootSpan.status.code === SpanStatusCode.ERROR && !is4xx
244
+ const isSkipped = this.config.skipPromotionCodes.includes(statusCode)
245
+
246
+ let shouldPromote = false
247
+ let promotionRate = this.config.samplingRate
248
+
249
+ if ((is5xx || isRootError) && !isSkipped) {
250
+ shouldPromote = getDecisionForBits(traceId, 12, 24, this.config.rate5xx)
251
+ promotionRate = this.config.rate5xx
252
+ } else if (is4xx && !isSkipped) {
253
+ shouldPromote = getDecisionForBits(traceId, 12, 24, this.config.rate4xx)
254
+ promotionRate = this.config.rate4xx
255
+ } else if (
256
+ this.config.promoteHandledErrors &&
257
+ record.hasChildError &&
258
+ statusCode >= 200 &&
259
+ statusCode < 300 &&
260
+ !isSkipped
261
+ ) {
262
+ shouldPromote = getDecisionForBits(traceId, 12, 24, this.config.rate5xx)
263
+ promotionRate = this.config.rate5xx
264
+ }
265
+
266
+ if (shouldPromote) {
267
+ this.promotedTraces.set(traceId, {
268
+ timestamp: Date.now(),
269
+ promotionRate,
270
+ })
271
+
272
+ let spansToExport = []
273
+ if (this.config.exportFullTraceOnError) {
274
+ spansToExport = [rootSpan, ...record.spans]
275
+ } else {
276
+ spansToExport = [rootSpan]
277
+ for (const s of record.spans) {
278
+ if (s.status && s.status.code === SpanStatusCode.ERROR) {
279
+ spansToExport.push(s)
280
+ }
281
+ }
282
+ }
283
+
284
+ const finalSampleRate = this.config.reportActualPromotionRate
285
+ ? promotionRate
286
+ : this.config.samplingRate
287
+ for (const s of spansToExport) {
288
+ // Required: batchProcessor.onEnd() drops spans without SAMPLED.
289
+ s.spanContext().traceFlags |= TraceFlags.SAMPLED
290
+ s.attributes["promoted"] = true
291
+ s.attributes["sample_rate"] = finalSampleRate
292
+ }
293
+
294
+ this._export(spansToExport)
295
+ }
296
+
297
+ this.buffer.delete(traceId)
298
+ }
299
+
300
+ _exportSpanImmediately(span, promotionRate) {
301
+ const finalSampleRate = this.config.reportActualPromotionRate
302
+ ? promotionRate
303
+ : this.config.samplingRate
304
+ // Required: batchProcessor.onEnd() drops spans without SAMPLED.
305
+ span.spanContext().traceFlags |= TraceFlags.SAMPLED
306
+ span.attributes["promoted"] = true
307
+ span.attributes["sample_rate"] = finalSampleRate
308
+
309
+ this._export([span])
310
+ }
311
+
312
+ // Routes promoted spans through the same BatchSpanProcessor as normal
313
+ // traffic, for its queueing/backpressure. Trade-off: they export on the
314
+ // next batch flush, not instantly. Skipping onStart() is safe — it's a no-op.
315
+ _export(spans) {
316
+ for (const s of spans) {
317
+ this.batchProcessor.onEnd(s)
318
+ }
319
+ }
320
+
321
+ _cleanupBuffers() {
322
+ const now = Date.now()
323
+ // Trade-off: short TTL bounds memory but drops buffered child spans (and
324
+ // hasChildError) for traces whose root outlives it — clock starts at the
325
+ // first span seen for that trace, not request start. The root itself is
326
+ // still evaluated fresh when it ends, so a late error can still promote,
327
+ // just without children. Revisit if this proves too aggressive.
328
+ const BUFFER_TTL = 60 * 1000 // 60 seconds TTL for unresolved buffer
329
+ const PROMOTED_TTL = 60 * 1000 // 60 seconds TTL for promoted trace cache
330
+ const mem = process.memoryUsage()
331
+
332
+ let expiredBuffer = 0
333
+ for (const [traceId, record] of this.buffer.entries()) {
334
+ if (now - record.timestamp > BUFFER_TTL) {
335
+ this.buffer.delete(traceId)
336
+ expiredBuffer++
337
+ } else {
338
+ break
339
+ }
340
+ }
341
+
342
+ if (expiredBuffer > 0) {
343
+ getLogger().info(
344
+ `🧹 [OTEL Cleanup] PromotingSpanProcessor: evicted ${expiredBuffer} expired unresolved trace(s) from buffer (remaining size=${this.buffer.size})`
345
+ )
346
+ }
347
+
348
+ let bufferOverflowEvicted = 0
349
+ if (this.buffer.size > 1024) {
350
+ const toDelete = this.buffer.size - 1024
351
+ for (const traceId of this.buffer.keys()) {
352
+ this.buffer.delete(traceId)
353
+ bufferOverflowEvicted++
354
+ if (bufferOverflowEvicted >= toDelete) break
355
+ }
356
+ // Buffer filling faster than roots resolve — traces dropped pre-promotion.
357
+ getLogger().warn(
358
+ `⚠️ [OTEL Buffer Overflow] PromotingSpanProcessor: buffer overflow, dropped ${bufferOverflowEvicted} unresolved trace(s) (buffer size=${this.buffer.size}, rss=${formatMB(mem.rss)}MB, heapUsed=${formatMB(mem.heapUsed)}MB)`
359
+ )
360
+ }
361
+
362
+ let expiredPromoted = 0
363
+ for (const [traceId, data] of this.promotedTraces.entries()) {
364
+ if (now - data.timestamp > PROMOTED_TTL) {
365
+ this.promotedTraces.delete(traceId)
366
+ expiredPromoted++
367
+ } else {
368
+ break
369
+ }
370
+ }
24
371
 
372
+ if (expiredPromoted > 0) {
373
+ getLogger().info(
374
+ `🧹 [OTEL Cleanup] PromotingSpanProcessor: evicted ${expiredPromoted} expired trace(s) from promotedTraces (remaining size=${this.promotedTraces.size})`
375
+ )
376
+ }
377
+
378
+ let promotedOverflowEvicted = 0
379
+ if (this.promotedTraces.size > 1024) {
380
+ const toDelete = this.promotedTraces.size - 1024
381
+ for (const traceId of this.promotedTraces.keys()) {
382
+ this.promotedTraces.delete(traceId)
383
+ promotedOverflowEvicted++
384
+ if (promotedOverflowEvicted >= toDelete) break
385
+ }
386
+ getLogger().warn(
387
+ `⚠️ [OTEL Buffer Overflow] PromotingSpanProcessor: promotedTraces overflow, dropped ${promotedOverflowEvicted} cache entry(s) (promotedTraces size=${this.promotedTraces.size}, rss=${formatMB(mem.rss)}MB, heapUsed=${formatMB(mem.heapUsed)}MB)`
388
+ )
389
+ }
390
+ }
391
+
392
+ forceFlush() {
393
+ return this.batchProcessor.forceFlush()
394
+ }
395
+
396
+ shutdown() {
397
+ if (this.cleanupInterval) {
398
+ clearInterval(this.cleanupInterval)
399
+ }
400
+ const mem = process.memoryUsage()
401
+ getLogger().info(
402
+ `📡 [OTEL Shutdown] PromotingSpanProcessor: shutting down, discarding ${this.buffer.size} unresolved trace(s) and ${this.promotedTraces.size} promoted-trace record(s) | Memory at exit: RSS=${formatMB(mem.rss)}MB, HeapUsed=${formatMB(mem.heapUsed)}MB`
403
+ )
404
+ this.buffer.clear()
405
+ this.promotedTraces.clear()
406
+ return this.batchProcessor.shutdown()
407
+ }
408
+ }
409
+
410
+ /**
411
+ * Initializes the OpenTelemetry Node SDK — traces, optional metrics, and (when
412
+ * errorSampling.ENABLED is set) status-aware error sampling. No-op unless
413
+ * OTEL_ENABLE is set (see server/expressServer.js and server/renderer/handler.jsx
414
+ * for the same guard on the call sites that import this module).
415
+ *
416
+ * @param {object} [config]
417
+ * @param {string} [config.serviceName="catalyst-server"]
418
+ * @param {string} [config.serviceVersion="1.0.0"]
419
+ * @param {string} [config.environment="development"]
420
+ * @param {string} [config.traceUrl="http://localhost:4317"] - OTLP trace collector endpoint
421
+ * @param {string} [config.metricUrl="http://localhost:4317"] - OTLP metric collector endpoint
422
+ * @param {string} [config.traceProtocol="grpc"] - "grpc" or "http"
423
+ * @param {string} [config.metricProtocol] - "grpc" or "http"; metrics stay disabled if omitted
424
+ * @param {object} [config.traceHeaders]
425
+ * @param {object} [config.metricHeaders]
426
+ * @param {object} [config.batchProcessorConfig] - passed to BatchSpanProcessor (maxQueueSize, scheduledDelayMillis, ...)
427
+ * @param {number} [config.exportIntervalMillis=10000]
428
+ * @param {number} [config.diagnosticsIntervalMillis=60000] - memory heartbeat interval, ms; requires errorSampling.ENABLED
429
+ * @param {boolean} [config.enableMemoryDiagnostics=true] - memory heartbeat + high-memory warnings; requires errorSampling.ENABLED
430
+ * @param {number} [config.highMemoryRssMB] - overrides the auto-detected RSS warning threshold; for testing; requires errorSampling.ENABLED
431
+ * @param {number} [config.highMemoryHeapUsedMB] - overrides the auto-detected heapUsed warning threshold; for testing; requires errorSampling.ENABLED
432
+ * @param {Array} [config.instrumentations] - defaults to getNodeAutoInstrumentations()
433
+ * @param {number} [config.samplingRate=1.0] - head-sampling rate in [0, 1] for non-error traffic
434
+ * @param {*} [config.grpcCredentials]
435
+ * @param {object} [config.errorSampling] - status-aware error sampling, off by default
436
+ * @param {boolean} [config.errorSampling.ENABLED=false] - turns on StatusAwareSampler + PromotingSpanProcessor
437
+ * @param {number} [config.errorSampling.RATE_5XX=1.0] - promotion rate for 5xx roots (and non-4xx roots with OTEL ERROR status)
438
+ * @param {number} [config.errorSampling.RATE_4XX=samplingRate] - promotion rate for 4xx roots
439
+ * @param {boolean} [config.errorSampling.EXPORT_FULL_TRACE_ON_ERROR=false] - export every buffered span on promotion, not just root + error spans
440
+ * @param {boolean} [config.errorSampling.PROMOTE_HANDLED_ERRORS=false] - promote 2xx roots (at RATE_5XX) if any child span recorded an error
441
+ * @param {boolean} [config.errorSampling.REPORT_ACTUAL_PROMOTION_RATE=false] - report the real promotion rate in the exported sample_rate attribute instead of samplingRate
442
+ * @param {number[]} [config.errorSampling.SKIP_PROMOTION_CODES=[408,504,524,598,599]] - status codes excluded from promotion regardless of 4xx/5xx
443
+ * @param {boolean} [config.errorSampling.PROMOTE_BOT_TRAFFIC=false] - allow bot-attributed traces (http.response.is_bot) to be promoted
444
+ * @returns {{sdk: NodeSDK|null, meter: object|null}}
445
+ */
25
446
  function init(config = {}) {
26
447
  // OpenTelemetry is opt-in — bail out unless explicitly enabled. Mirrors the
27
448
  // OTEL_ENABLE guard in server/expressServer.js and server/renderer/handler.jsx
@@ -31,6 +452,9 @@ function init(config = {}) {
31
452
  return { sdk: null, meter: null }
32
453
  }
33
454
 
455
+ // Routes export failures to the app logger instead of OTEL's silent default.
456
+ setGlobalErrorHandler((err) => getLogger().error("❌ OpenTelemetry export error:", err))
457
+
34
458
  const {
35
459
  serviceName = "catalyst-server",
36
460
  serviceVersion = "1.0.0",
@@ -43,9 +467,14 @@ function init(config = {}) {
43
467
  metricHeaders = {},
44
468
  batchProcessorConfig = {},
45
469
  exportIntervalMillis = 10000,
470
+ diagnosticsIntervalMillis = 60000,
471
+ enableMemoryDiagnostics = true,
472
+ highMemoryRssMB,
473
+ highMemoryHeapUsedMB,
46
474
  instrumentations,
47
475
  samplingRate = 1.0,
48
476
  grpcCredentials,
477
+ errorSampling = {},
49
478
  } = config
50
479
 
51
480
  try {
@@ -67,9 +496,36 @@ function init(config = {}) {
67
496
  })
68
497
  }
69
498
 
70
- const sampler = new ParentBasedSampler({
71
- root: new TraceIdRatioBasedSampler(samplingRate),
72
- })
499
+ let sampler
500
+ let spanProcessor
501
+
502
+ const isErrorSamplingEnabled = errorSampling && errorSampling.ENABLED === true
503
+
504
+ if (isErrorSamplingEnabled) {
505
+ getLogger().info("⚙️ OpenTelemetry initializing with Status Aware Error Sampling")
506
+ sampler = new StatusAwareSampler(samplingRate)
507
+
508
+ const errorSamplingConfig = {
509
+ samplingRate,
510
+ rate5xx: typeof errorSampling.RATE_5XX === "number" ? errorSampling.RATE_5XX : 1.0,
511
+ rate4xx: typeof errorSampling.RATE_4XX === "number" ? errorSampling.RATE_4XX : samplingRate,
512
+ exportFullTraceOnError: errorSampling.EXPORT_FULL_TRACE_ON_ERROR === true,
513
+ promoteHandledErrors: errorSampling.PROMOTE_HANDLED_ERRORS === true,
514
+ reportActualPromotionRate: errorSampling.REPORT_ACTUAL_PROMOTION_RATE === true,
515
+ skipPromotionCodes: Array.isArray(errorSampling.SKIP_PROMOTION_CODES)
516
+ ? errorSampling.SKIP_PROMOTION_CODES
517
+ : [408, 504, 524, 598, 599],
518
+ promoteBotTraffic: errorSampling.PROMOTE_BOT_TRAFFIC === true,
519
+ batchProcessorConfig,
520
+ }
521
+
522
+ spanProcessor = new PromotingSpanProcessor(otlpTraceExporter, errorSamplingConfig)
523
+ } else {
524
+ sampler = new ParentBasedSampler({
525
+ root: new TraceIdRatioBasedSampler(samplingRate),
526
+ })
527
+ spanProcessor = new BatchSpanProcessor(otlpTraceExporter, batchProcessorConfig)
528
+ }
73
529
 
74
530
  const sdkConfig = {
75
531
  resource: resourceFromAttributes({
@@ -77,7 +533,7 @@ function init(config = {}) {
77
533
  [ATTR_SERVICE_VERSION]: serviceVersion,
78
534
  [ATTR_DEPLOYMENT_ENVIRONMENT]: environment,
79
535
  }),
80
- spanProcessor: new BatchSpanProcessor(otlpTraceExporter, batchProcessorConfig),
536
+ spanProcessor,
81
537
  instrumentations: instrumentations ?? [getNodeAutoInstrumentations()],
82
538
  sampler,
83
539
  }
@@ -92,6 +548,32 @@ function init(config = {}) {
92
548
  sdk.start()
93
549
  getLogger().info("✅ OpenTelemetry started successfully")
94
550
 
551
+ // Only under status-aware sampling: that's the only path with unbounded
552
+ // buffer growth to watch. Plain BatchSpanProcessor's queue is already bounded.
553
+ let heartbeatInterval = null
554
+ if (isErrorSamplingEnabled && enableMemoryDiagnostics) {
555
+ const memoryThresholds = { rssMB: highMemoryRssMB, heapUsedMB: highMemoryHeapUsedMB }
556
+ heartbeatInterval = setInterval(() => {
557
+ const mem = process.memoryUsage()
558
+ const ratio = (mem.rss / (mem.heapUsed || 1)).toFixed(2)
559
+ const bufferInfo = ` | Buffers: active=${spanProcessor.buffer.size}, promotedCache=${spanProcessor.promotedTraces.size}`
560
+ // Not ratioed against memory.request — no cgroup file exposes it.
561
+ const cgroupCurrentBytes = getCgroupCurrentBytes()
562
+ const cgroupInfo =
563
+ cgroupCurrentBytes !== undefined
564
+ ? ` | CgroupCurrent=${formatMB(cgroupCurrentBytes)}MB`
565
+ : ""
566
+ getLogger().info(
567
+ `📊 [OTEL Heartbeat] Memory: RSS=${formatMB(mem.rss)}MB, HeapUsed=${formatMB(mem.heapUsed)}MB, HeapTotal=${formatMB(mem.heapTotal)}MB, External=${formatMB(mem.external)}MB (RSS/Heap: ${ratio})${cgroupInfo}${bufferInfo} | Uptime: ${process.uptime().toFixed(0)}s`
568
+ )
569
+ checkHighMemoryAlert(mem, "heartbeat", memoryThresholds)
570
+ }, diagnosticsIntervalMillis)
571
+
572
+ if (heartbeatInterval.unref) {
573
+ heartbeatInterval.unref()
574
+ }
575
+ }
576
+
95
577
  // Initialize custom metrics only if metrics are enabled
96
578
  let meter = null
97
579
  if (metricProtocol) {
@@ -99,7 +581,14 @@ function init(config = {}) {
99
581
  }
100
582
 
101
583
  const gracefulShutdown = (signal) => {
102
- getLogger().info(`📡 Received ${signal}, shutting down OpenTelemetry gracefully...`)
584
+ if (heartbeatInterval) {
585
+ clearInterval(heartbeatInterval)
586
+ }
587
+ const mem = process.memoryUsage()
588
+ const uptime = process.uptime().toFixed(1)
589
+ getLogger().info(
590
+ `📡 [OTEL Shutdown] Received ${signal}, shutting down OpenTelemetry gracefully... [Uptime: ${uptime}s, Memory at exit: RSS=${formatMB(mem.rss)}MB, HeapUsed=${formatMB(mem.heapUsed)}MB, HeapTotal=${formatMB(mem.heapTotal)}MB, External=${formatMB(mem.external)}MB]`
591
+ )
103
592
  sdk.shutdown()
104
593
  .then(() => getLogger().info("✅ OpenTelemetry shutdown completed"))
105
594
  .catch((error) => {
@@ -361,31 +361,39 @@ async function build() {
361
361
  // Lets `serve` refuse a build that no longer matches config/config.json.
362
362
  writeBuildInfo(process.env.PWD, configJSON, argumentsObject)
363
363
 
364
- const manifest = new Spinner().start("Generating offline manifest")
365
- try {
366
- await runBuildStep(
367
- ["./dist/scripts/generateOfflineManifest.js"],
368
- { ...spawnBase, env: baseEnv },
369
- "Generating the offline manifest"
370
- )
371
- } catch (error) {
372
- manifest.stop("fail", "Generating offline manifest failed")
373
- // Without this the manifest step fell through to the top-level handler,
374
- // which prints a raw stack trace. The step's output is captured, so
375
- // show its tail -- the exit code alone says nothing about why -- and
376
- // all of it under --verbose/--debug.
377
- process.stderr.write(
378
- diagnostic({
379
- message: `Generating the offline manifest failed: ${error.message}`,
380
- scope: "offline manifest",
381
- }) + (outputMode === "default" ? fencedTail(error.output) : "")
364
+ // Same rule as isOfflineSnapshotsEnabled in vite.config.js.
365
+ const offlineSnapshotsDisabled = [false, "false"].includes(configJSON.OFFLINE_SNAPSHOTS_ENABLE)
366
+ if (offlineSnapshotsDisabled) {
367
+ console.log(
368
+ `${GUTTER}${t.dim(glyph.info)} ${t.dim("Skipped offline manifest (OFFLINE_SNAPSHOTS_ENABLE is false)")}`
382
369
  )
383
- if (outputMode !== "default") {
384
- process.stderr.write(verboseOutput({ output: error.output, mode: outputMode, error }))
370
+ } else {
371
+ const manifest = new Spinner().start("Generating offline manifest")
372
+ try {
373
+ await runBuildStep(
374
+ ["./dist/scripts/generateOfflineManifest.js"],
375
+ { ...spawnBase, env: baseEnv },
376
+ "Generating the offline manifest"
377
+ )
378
+ } catch (error) {
379
+ manifest.stop("fail", "Generating offline manifest failed")
380
+ // Without this the manifest step fell through to the top-level handler,
381
+ // which prints a raw stack trace. The step's output is captured, so
382
+ // show its tail -- the exit code alone says nothing about why -- and
383
+ // all of it under --verbose/--debug.
384
+ process.stderr.write(
385
+ diagnostic({
386
+ message: `Generating the offline manifest failed: ${error.message}`,
387
+ scope: "offline manifest",
388
+ }) + (outputMode === "default" ? fencedTail(error.output) : "")
389
+ )
390
+ if (outputMode !== "default") {
391
+ process.stderr.write(verboseOutput({ output: error.output, mode: outputMode, error }))
392
+ }
393
+ process.exit(exitCodeFrom(error))
385
394
  }
386
- process.exit(exitCodeFrom(error))
395
+ manifest.stop("done", "Generated offline manifest")
387
396
  }
388
- manifest.stop("done", "Generated offline manifest")
389
397
 
390
398
  printAssetTable(buildOutputPath, configJSON.BUILD_OUTPUT_PATH || "build")
391
399
 
@@ -27,18 +27,19 @@ export function Body(props) {
27
27
  fetcherData = {},
28
28
  children,
29
29
  safeArea = DEFAULT_SAFE_AREA,
30
- nativeWebView = false
30
+ nativeWebView = false,
31
+ nonce
31
32
  } = props;
32
33
  return <body>
33
- <script
34
+ <script nonce={nonce}
34
35
  /* eslint-disable-next-line risxss/catch-potential-xss-react */ dangerouslySetInnerHTML={{
35
36
  __html: `window.__SAFE_AREA_INITIAL__ = ${JSON.stringify(safeArea)}; window.__CATALYST_NATIVE_WEBVIEW__ = ${nativeWebView ? "true" : "false"}`
36
37
  }} />
37
38
  {process.env.NODE_ENV === "development" &&
38
39
  // Base pinned to Vite's resolved base by server/expressServer.js.
39
- <script type="module" src={`${process.env.APP_MOUNT_PATH || ""}/client/index.js`}></script>}
40
+ <script type="module" nonce={nonce} src={`${process.env.APP_MOUNT_PATH || ""}/client/index.js`}></script>}
40
41
  {jsx}
41
- <script
42
+ <script nonce={nonce}
42
43
  /* eslint-disable */ dangerouslySetInnerHTML={{
43
44
  __html: `
44
45
  window.__INITIAL_STATE__ = ${serialize(initialState)}
@@ -16,12 +16,13 @@ export function Head(props) {
16
16
  metaTags,
17
17
  isBot,
18
18
  publicAssetPath,
19
+ nonce,
19
20
  children
20
21
  } = props;
21
22
  return <head>
22
23
  <meta charSet="utf-8" />
23
24
  <meta name="viewport" content="width=device-width, initial-scale=1" />
24
- {process.env.NODE_ENV === "development" && <FastRefresh />}
25
+ {process.env.NODE_ENV === "development" && <FastRefresh nonce={nonce} />}
25
26
 
26
27
  {publicAssetPath && <link rel="preconnect" href={publicAssetPath} />}
27
28
  {publicAssetPath && <link rel="dns-prefetch" href={publicAssetPath} />}