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/changelog.md +27 -1
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/CustomWebview.kt +72 -0
- package/dist/native/buildAndroid/assets.js +24 -1
- package/dist/native/buildAndroid/index.js +4 -1
- package/dist/native/buildIos/config.js +6 -0
- package/dist/offline/registerServiceWorker.js +2 -0
- package/dist/otel.js +503 -14
- package/dist/scripts/build.js +30 -22
- package/dist/server/renderer/document/Body.jsx +5 -4
- package/dist/server/renderer/document/Head.jsx +2 -1
- package/dist/server/renderer/extract.d.ts +11 -4
- package/dist/server/renderer/extract.js +21 -7
- package/dist/server/renderer/handler.jsx +32 -14
- package/dist/vite/FastRefresh.jsx +2 -1
- package/dist/vite/vite.config.js +33 -3
- package/dist/web-router/components/RouterDataProvider.jsx +5 -3
- package/dist/web-router/components/RouterDataProvider.server.test.jsx +16 -1
- package/dist/web-router/components/RouterDataProvider.test.jsx +3 -1
- package/package.json +1 -1
- package/test/native/locationPermissions.test.js +56 -0
- package/test/server/handler.assets.test.ts +77 -0
- package/test/server/otel.test.ts +3 -2
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 {
|
|
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
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
71
|
-
|
|
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
|
|
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
|
-
|
|
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) => {
|
package/dist/scripts/build.js
CHANGED
|
@@ -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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
{
|
|
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
|
-
|
|
384
|
-
|
|
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
|
-
|
|
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} />}
|