whatap 1.0.14 → 2.0.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.
@@ -1,7 +1,14 @@
1
1
  {
2
2
  "permissions": {
3
3
  "allow": [
4
- "Bash(grep -n \"ECONNREFUSED\" /Users/seunghunlee/agent_workspace/nodejs_agent/lib/observers/*.js /Users/seunghunlee/agent_workspace/nodejs_agent/lib/core/agent.js 2>/dev/null)"
4
+ "Bash(grep -n \"ECONNREFUSED\" /Users/seunghunlee/agent_workspace/nodejs_agent/lib/observers/*.js /Users/seunghunlee/agent_workspace/nodejs_agent/lib/core/agent.js 2>/dev/null)",
5
+ "WebSearch",
6
+ "Bash(go env *)",
7
+ "Bash(ls -l \"$\\(go env GOPATH\\)/bin/govulncheck\")",
8
+ "Bash(brew list *)",
9
+ "Bash(brew search *)",
10
+ "Bash(brew info *)",
11
+ "Bash(echo \"--- exit $? ---\")"
5
12
  ]
6
13
  }
7
14
  }
Binary file
Binary file
Binary file
package/build.txt CHANGED
@@ -1,4 +1,4 @@
1
1
  app = 'NodeJS'
2
2
  name = 'whatap_nodejs'
3
- version = '1.0.13'
4
- build_date = '20260224'
3
+ version = '1.0.15'
4
+ build_date = '20260423'
@@ -285,7 +285,12 @@ var ConfigDefault = {
285
285
  "trace_kafka_enabled": bool("trace_kafka_enabled", false),
286
286
  "trace_kafka_consumer_enabled": bool("trace_kafka_consumer_enabled", false),
287
287
 
288
- "agent_per_instance_enabled": bool("agent_per_instance_enabled", false)
288
+ "agent_per_instance_enabled": bool("agent_per_instance_enabled", false),
289
+
290
+ // LLM monitoring
291
+ "llm_enabled": bool("llm_enabled", false),
292
+ "llm_net_udp_port": num("llm_net_udp_port", 0),
293
+ "llm_model_pricing": str("llm_model_pricing", "")
289
294
 
290
295
  };
291
296
 
package/lib/core/agent.js CHANGED
@@ -37,7 +37,8 @@ var Interceptor = require('./interceptor').Interceptor,
37
37
  OracleObserver = require('../observers/oracle-observer').OracleObserver,
38
38
  CustomMethodObserver = require('../observers/custom-method-observer').CustomMethodObserver,
39
39
  CronObserver = require('../observers/cron-observer').CronObserver,
40
- KafkaObserver = require('../observers/kafka-observer').KafkaObserver;
40
+ KafkaObserver = require('../observers/kafka-observer').KafkaObserver,
41
+ OpenAIObserver = require('../observers/openai-observer').OpenAIObserver;
41
42
 
42
43
 
43
44
  var Configuration = require('./../conf/configure'),
@@ -996,7 +997,7 @@ NodeAgent.prototype.startGoAgent = function(opts = {}) {
996
997
  if (pid) {
997
998
  try {
998
999
  if (this.isGoAgentRunning(pid)) {
999
- // Windows: 기존 프로세스가 실행 중이면 스킵 (Python agent와 동일한 동작)
1000
+ // Windows: 기존 프로세스가 실행 중이면 스킵
1000
1001
  // Unix/Linux: 기존 프로세스 종료 후 새로 시작
1001
1002
  if (process.platform === 'win32') {
1002
1003
  Logger.print("WHATAP-106", `Agent already running (PID: ${pid}). Skipping duplicate execution.`, false);
@@ -1249,6 +1250,106 @@ NodeAgent.prototype.startGoAgent = function(opts = {}) {
1249
1250
  }
1250
1251
  };
1251
1252
 
1253
+ /**
1254
+ * Start a separate Go Agent for LLM monitoring (with --llm flag)
1255
+ */
1256
+ NodeAgent.prototype.startLlmGoAgent = function () {
1257
+ var self = this;
1258
+ var whatapHome = process.env.WHATAP_HOME;
1259
+ if (!whatapHome) return;
1260
+
1261
+ try {
1262
+ var platform = process.platform;
1263
+ var agentBinaryName = platform === 'win32' ? AGENT_NAME + '.exe' : AGENT_NAME;
1264
+ var agentPath = path.join(whatapHome, agentBinaryName);
1265
+
1266
+ if (!fs.existsSync(agentPath)) {
1267
+ Logger.print("WHATAP-LLM-020", "Agent binary not found for LLM agent: " + agentPath, false);
1268
+ return;
1269
+ }
1270
+
1271
+ // LLM UDP port
1272
+ var basePort = parseInt(self._conf.getProperty('net_udp_port', 6600)) || 6600;
1273
+ var llmPort = parseInt(self._conf.getProperty('llm_net_udp_port', 0)) || (basePort + 100);
1274
+ self._conf['llm_net_udp_port'] = llmPort;
1275
+
1276
+ // Write to whatap.conf so Go Agent can read it
1277
+ try {
1278
+ var confFile = path.join(whatapHome, 'whatap.conf');
1279
+ if (fs.existsSync(confFile)) {
1280
+ var confContent = fs.readFileSync(confFile, 'utf8');
1281
+ if (confContent.indexOf('llm_net_udp_port') < 0) {
1282
+ fs.appendFileSync(confFile, '\nllm_net_udp_port=' + llmPort + '\n');
1283
+ }
1284
+ }
1285
+ } catch (e) {
1286
+ Logger.printError("WHATAP-LLM-021", "Error writing llm_net_udp_port to whatap.conf", e, false);
1287
+ }
1288
+
1289
+ // Check existing LLM agent
1290
+ var llmPidFile = path.join(whatapHome, AGENT_NAME + '.pid.llm');
1291
+ if (fs.existsSync(llmPidFile)) {
1292
+ try {
1293
+ var existingPid = parseInt(fs.readFileSync(llmPidFile, 'utf8').trim());
1294
+ if (existingPid && self.isGoAgentRunning(existingPid)) {
1295
+ Logger.print("WHATAP-LLM-022", "LLM Go Agent already running (PID: " + existingPid + ")", false);
1296
+ return;
1297
+ }
1298
+ } catch (e) {}
1299
+ }
1300
+
1301
+ // Build environment
1302
+ var newEnv = Object.assign({}, process.env);
1303
+ newEnv['WHATAP_NET_UDP_PORT'] = String(llmPort);
1304
+ newEnv['WHATAP_PID_FILE'] = AGENT_NAME + '.pid.llm';
1305
+ newEnv['NODEJS_PARENT_APP_PID'] = process.pid.toString();
1306
+ newEnv['whatap.enabled'] = 'true';
1307
+
1308
+ // Spawn: whatap_nodejs -t 2 -d 1 --llm (normal agent와 동일 -t/-d, --llm만 추가)
1309
+ var spawnArgs = platform === 'win32'
1310
+ ? ['-t', '2', '--llm', 'foreground']
1311
+ : ['-t', '2', '-d', '1', '--llm'];
1312
+
1313
+ var spawnOptions = {
1314
+ cwd: whatapHome,
1315
+ env: newEnv,
1316
+ stdio: ['pipe', 'pipe', 'pipe'],
1317
+ detached: true, //
1318
+ };
1319
+
1320
+ if (platform === 'win32') {
1321
+ spawnOptions.windowsHide = true;
1322
+ }
1323
+
1324
+ var llmProcess = child_process.spawn(agentPath, spawnArgs, spawnOptions);
1325
+
1326
+ llmProcess.on('spawn', function () {
1327
+ Logger.print("WHATAP-LLM-023", "executed LLM golang module in background (PID: " + llmProcess.pid + ")", false);
1328
+ try {
1329
+ fs.writeFileSync(llmPidFile, String(llmProcess.pid));
1330
+ } catch (e) {}
1331
+ Logger.print("WHATAP-LLM-028", "WHATAP: AGENT UP! (process name: " + AGENT_NAME + " --llm)", false);
1332
+ });
1333
+
1334
+ llmProcess.on('error', function (err) {
1335
+ Logger.printError("WHATAP-LLM-024", "Failed to spawn LLM Go Agent", err, false);
1336
+ });
1337
+
1338
+ llmProcess.on('close', function (code) {
1339
+ Logger.print("WHATAP-LLM-025", "LLM Go Agent exited with code: " + code, false);
1340
+ });
1341
+
1342
+ // Capture stderr for logging
1343
+ llmProcess.stderr.on('data', function (data) {
1344
+ var msg = data.toString().trim();
1345
+ if (msg) Logger.print("WHATAP-LLM-026", "[LLM Agent] " + msg, false);
1346
+ });
1347
+
1348
+ } catch (e) {
1349
+ Logger.printError("WHATAP-LLM-027", "Error starting LLM Go Agent", e, false);
1350
+ }
1351
+ };
1352
+
1252
1353
  // 프로세스 종료 시 정리 작업 개선
1253
1354
  process.on('exit', () => {
1254
1355
  if (!process.env.WHATAP_HOME || !NodeAgent.prototype.getApplicationIdentifier || !NodeAgent.prototype.isPM2ClusterMode) {
@@ -1374,6 +1475,12 @@ NodeAgent.prototype.init = function(cb) {
1374
1475
  // Start Node.js agent with proper port configuration
1375
1476
  WhatapUtil.printWhatap();
1376
1477
  self.startGoAgent();
1478
+
1479
+ // Start LLM Go Agent if llm_enabled
1480
+ if (self._conf.getProperty('llm_enabled', false)) {
1481
+ self.startLlmGoAgent();
1482
+ }
1483
+
1377
1484
  TraceContextManager.initialized = true;
1378
1485
 
1379
1486
  self.initUdp();
@@ -1413,6 +1520,7 @@ NodeAgent.prototype.loadObserves = function() {
1413
1520
  observes.push(OracleObserver);
1414
1521
  observes.push(CronObserver);
1415
1522
  observes.push(KafkaObserver);
1523
+ observes.push(OpenAIObserver);
1416
1524
 
1417
1525
  var packageToObserve = {};
1418
1526
  observes.forEach(function(observeObj) {
@@ -6,7 +6,8 @@
6
6
 
7
7
  var conf = require('../conf/configure'),
8
8
  Logger = require('../logger'),
9
- GCAction = require('../system/gc-action');
9
+ GCAction = require('../system/gc-action'),
10
+ LlmStatTasks = require('./task/llm-stat-tasks');
10
11
 
11
12
  function CounterManager(agent) {
12
13
  this.agent = agent;
@@ -21,6 +22,14 @@ CounterManager.prototype.run = function () {
21
22
  var tasks = [];
22
23
  tasks.push(new GCAction());
23
24
 
25
+ // 호출 카운트는 ACTIVE_STATS 꼬리의 "LLM:count=N"으로 전송 (_llmMeterCount).
26
+ tasks.push(new LlmStatTasks.LLMActiveStatTask());
27
+ tasks.push(new LlmStatTasks.LLMApiStatusTask());
28
+ tasks.push(new LlmStatTasks.LLMErrorStatTask());
29
+ tasks.push(new LlmStatTasks.LLMFeatureStatTask());
30
+ tasks.push(new LlmStatTasks.LLMPerfStatTask());
31
+ tasks.push(new LlmStatTasks.LLMTokenUsageTask());
32
+
24
33
  self.intervalIndex = setInterval(function(){
25
34
  self.process(tasks);
26
35
  },5000);
@@ -0,0 +1,268 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * LLM Metric counter tasks (7 categories):
5
+ * - llm_active_stat
6
+ * - llm_api_status
7
+ * - llm_error_stat
8
+ * - llm_feature_stat
9
+ * - llm_perf_stat (sketch fields omitted; Node has no datasketches)
10
+ * - llm_token_usage
11
+ * - meter (LLM_TRANSACTION)
12
+ *
13
+ * Each task exposes a process() method invoked by counter-manager.
14
+ * Packs are sent via async_sender.send_llm_relaypack to the LLM Go agent.
15
+ */
16
+
17
+ const TagCountPack = require('../../pack/tagcount-pack');
18
+ const DataOutputX = require('../../io/data-outputx');
19
+ const AsyncSender = require('../../udp/async_sender');
20
+ const conf = require('../../conf/configure');
21
+ const Logger = require('../../logger');
22
+ const HashUtil = require('../../util/hashutil');
23
+ const collector = require('../../llm/llm-stat-collector');
24
+ const { getLlmPcode } = require('../../llm/llm-pcode');
25
+
26
+ const _PID = process.pid;
27
+
28
+ function _enabled() {
29
+ return conf.getProperty('llm_enabled', false);
30
+ }
31
+
32
+ function _bucketTime(ms) {
33
+ return Math.floor(ms / 5000) * 5000;
34
+ }
35
+
36
+ function _send(p) {
37
+ try {
38
+ p.pcode = getLlmPcode();
39
+ const bout = new DataOutputX();
40
+ bout.writePack(p, null);
41
+ AsyncSender.send_llm_relaypack(bout.toByteArray());
42
+ } catch (e) {
43
+ Logger.printError('WHATAP-LLM-STAT-001', 'send pack failed', e, false);
44
+ }
45
+ }
46
+
47
+ function _newPack(category, model, provider, opType, url) {
48
+ const p = new TagCountPack();
49
+ p.time = _bucketTime(Date.now());
50
+ p.category = category;
51
+ p.tags.putLong('pid', _PID);
52
+ p.tags.putString('model', model || 'unknown');
53
+ p.tags.putString('provider', provider || '');
54
+ p.tags.putString('operation_type', opType || 'unknown');
55
+ p.tags.putString('url', url || '');
56
+ return p;
57
+ }
58
+
59
+ // ---------------------------------------------------------
60
+ // 1) llm_active_stat
61
+ // ---------------------------------------------------------
62
+ function LLMActiveStatTask() {}
63
+ LLMActiveStatTask.prototype.process = function () {
64
+ if (!_enabled()) return;
65
+ // 매 tick에 model cache TTL 만료 entry 제거 (10분/500개 cap)
66
+ try { collector.pruneModelCache(); } catch (e) {}
67
+
68
+ const snap = collector.snapshotAndResetActive();
69
+ if (snap.active.size === 0) return;
70
+ snap.active.forEach((count, k) => {
71
+ try {
72
+ const parsed = collector.parseKey(k);
73
+ const info = snap.modelInfo.get(parsed.model);
74
+ const provider = info ? info.provider : '';
75
+ const url = info ? info.url : '';
76
+ const p = _newPack('llm_active_stat', parsed.model, provider, parsed.op_type, url);
77
+ p.fields.putLong('count', count);
78
+ _send(p);
79
+ } catch (e) {
80
+ Logger.printError('WHATAP-LLM-STAT-010', 'active stat error', e, false);
81
+ }
82
+ });
83
+ };
84
+
85
+ // ---------------------------------------------------------
86
+ // 2) llm_api_status
87
+ // ---------------------------------------------------------
88
+ function LLMApiStatusTask() {}
89
+ LLMApiStatusTask.prototype.process = function () {
90
+ if (!_enabled()) return;
91
+ const snap = collector.snapshotAndResetApiStatus();
92
+ if (snap.x4.size === 0 && snap.x5.size === 0) return;
93
+
94
+ // Set spread → Map iteration으로 GC 압박 감소 (intermediate array 제거)
95
+ const seen = new Set();
96
+ const emit = (k) => {
97
+ if (seen.has(k)) return;
98
+ seen.add(k);
99
+ try {
100
+ const parsed = collector.parseKey(k);
101
+ const p = _newPack('llm_api_status', parsed.model, parsed.provider, parsed.op_type, parsed.url);
102
+ p.fields.putLong('4xx_total_count', snap.x4.get(k) || 0);
103
+ p.fields.putLong('5xx_total_count', snap.x5.get(k) || 0);
104
+ _send(p);
105
+ } catch (e) {
106
+ Logger.printError('WHATAP-LLM-STAT-020', 'api status error', e, false);
107
+ }
108
+ };
109
+ snap.x4.forEach((_, k) => emit(k));
110
+ snap.x5.forEach((_, k) => emit(k));
111
+ };
112
+
113
+ // ---------------------------------------------------------
114
+ // 3) llm_error_stat
115
+ // ---------------------------------------------------------
116
+ function LLMErrorStatTask() {}
117
+ LLMErrorStatTask.prototype.process = function () {
118
+ if (!_enabled()) return;
119
+ const snap = collector.snapshotAndResetErrors();
120
+ if (snap.api.size === 0 && snap.program.size === 0 && snap.lastApi.size === 0) return;
121
+
122
+ const seen = new Set();
123
+ const emit = (k) => {
124
+ if (seen.has(k)) return;
125
+ seen.add(k);
126
+ try {
127
+ const parsed = collector.parseKey(k);
128
+ const apiE = snap.api.get(k) || 0;
129
+ const progE = snap.program.get(k) || 0;
130
+ const p = _newPack('llm_error_stat', parsed.model, parsed.provider, parsed.op_type, parsed.url);
131
+ p.fields.putLong('error_count', apiE + progE);
132
+ p.fields.putLong('api_error_count', apiE);
133
+ p.fields.putLong('program_error_count', progE);
134
+ p.fields.putLong('last_api_error_count', snap.lastApi.get(k) || 0);
135
+ _send(p);
136
+ } catch (e) {
137
+ Logger.printError('WHATAP-LLM-STAT-030', 'error stat error', e, false);
138
+ }
139
+ };
140
+ snap.api.forEach((_, k) => emit(k));
141
+ snap.program.forEach((_, k) => emit(k));
142
+ snap.lastApi.forEach((_, k) => emit(k));
143
+ };
144
+
145
+ // ---------------------------------------------------------
146
+ // 4) llm_feature_stat (리스트형 → !rectype=2)
147
+ // ---------------------------------------------------------
148
+ function LLMFeatureStatTask() {}
149
+ LLMFeatureStatTask.prototype.process = function () {
150
+ if (!_enabled()) return;
151
+ const snap = collector.snapshotAndResetFeatures();
152
+ if (snap.calls.size === 0) return;
153
+ snap.calls.forEach((callCount, k) => {
154
+ try {
155
+ const parsed = collector.parseKey(k);
156
+ const p = _newPack('llm_feature_stat', parsed.model, parsed.provider, parsed.op_type, parsed.url);
157
+ p.tags.putLong('!rectype', 2);
158
+ p.fields.putLong('call_count', callCount);
159
+
160
+ const idList = p.fields.newList('@id');
161
+ const fList = p.fields.newList('features');
162
+ const fCntList = p.fields.newList('features_count');
163
+ const counts = snap.counts.get(k) || {};
164
+ for (const feat of collector.FEATURES) {
165
+ const c = counts[feat] || 0;
166
+ if (c > 0) {
167
+ idList.addLong(HashUtil.hashFromString(`${parsed.model}:${parsed.provider}:${parsed.op_type}:${feat}`));
168
+ fList.addString(feat);
169
+ fCntList.addLong(c);
170
+ }
171
+ }
172
+ _send(p);
173
+ } catch (e) {
174
+ Logger.printError('WHATAP-LLM-STAT-040', 'feature stat error', e, false);
175
+ }
176
+ });
177
+ };
178
+
179
+ // ---------------------------------------------------------
180
+ // 5) llm_perf_stat (sketch 미지원 — sum/count만 전송)
181
+ // ---------------------------------------------------------
182
+ function LLMPerfStatTask() {}
183
+ LLMPerfStatTask.prototype.process = function () {
184
+ if (!_enabled()) return;
185
+ const snap = collector.snapshotAndResetPerf();
186
+ if (snap.callCount.size === 0) return;
187
+ snap.callCount.forEach((call, k) => {
188
+ try {
189
+ const parsed = collector.parseKey(k);
190
+ const p = _newPack('llm_perf_stat', parsed.model, parsed.provider, parsed.op_type, parsed.url);
191
+ p.fields.putLong('call_count', call);
192
+ p.fields.putFloat('latency_sum', snap.latencySum.get(k) || 0);
193
+ p.fields.putFloat('ttft_sum', snap.ttftSum.get(k) || 0);
194
+ p.fields.putLong('ttft_count', snap.ttftCount.get(k) || 0);
195
+ p.fields.putFloat('tpot_sum', snap.tpotSum.get(k) || 0);
196
+ p.fields.putLong('tpot_count', snap.tpotCount.get(k) || 0);
197
+ // sketch 필드는 datasketches 미지원으로 omit
198
+ _send(p);
199
+ } catch (e) {
200
+ Logger.printError('WHATAP-LLM-STAT-050', 'perf stat error', e, false);
201
+ }
202
+ });
203
+ };
204
+
205
+ // ---------------------------------------------------------
206
+ // 6) llm_token_usage (리스트형 → !rectype=2)
207
+ // ---------------------------------------------------------
208
+ const _TOKEN_TYPES = ['input_tokens', 'output_tokens', 'cached_tokens', 'reasoning_tokens',
209
+ 'cache_creation_input_tokens', 'cache_read_input_tokens'];
210
+ const _TOKEN_COST_MAP = {
211
+ 'input_tokens': 'input_cost',
212
+ 'output_tokens': 'output_cost',
213
+ 'cached_tokens': 'cached_cost',
214
+ };
215
+
216
+ function LLMTokenUsageTask() {}
217
+ LLMTokenUsageTask.prototype.process = function () {
218
+ if (!_enabled()) return;
219
+ const snap = collector.snapshotAndResetTokens();
220
+ if (snap.calls.size === 0) return;
221
+ snap.calls.forEach((call, k) => {
222
+ try {
223
+ const parsed = collector.parseKey(k);
224
+ const counts = snap.counts.get(k) || {};
225
+ const costs = snap.costs.get(k) || {};
226
+ const totalTokens = (counts.input_tokens || 0) + (counts.output_tokens || 0);
227
+ const totalCost = (costs.input_cost || 0) + (costs.output_cost || 0);
228
+
229
+ const p = _newPack('llm_token_usage', parsed.model, parsed.provider, parsed.op_type, parsed.url);
230
+ p.tags.putLong('!rectype', 2);
231
+ p.fields.putLong('call_count', call);
232
+ p.fields.putLong('total_tokens', totalTokens);
233
+ p.fields.putFloat('total_cost', +totalCost.toFixed(6));
234
+ p.fields.putLong('error_count', snap.error.get(k) || 0);
235
+ p.fields.putLong('stream_count', snap.stream.get(k) || 0);
236
+
237
+ const idList = p.fields.newList('@id');
238
+ const tokList = p.fields.newList('tokens');
239
+ const cntList = p.fields.newList('tokens_count');
240
+ const costList = p.fields.newList('tokens_cost');
241
+ for (const tt of _TOKEN_TYPES) {
242
+ const c = counts[tt] || 0;
243
+ if (c > 0) {
244
+ idList.addLong(HashUtil.hashFromString(`${parsed.model}:${parsed.provider}:${parsed.op_type}:${tt}`));
245
+ tokList.addString(tt);
246
+ cntList.addLong(c);
247
+ const costKey = _TOKEN_COST_MAP[tt];
248
+ costList.addFloat(costKey ? +(costs[costKey] || 0).toFixed(6) : 0);
249
+ }
250
+ }
251
+ _send(p);
252
+ } catch (e) {
253
+ Logger.printError('WHATAP-LLM-STAT-060', 'token usage error', e, false);
254
+ }
255
+ });
256
+ };
257
+
258
+ // LLM 호출 카운트는 _llmMeterCount로 유지되어 ACTIVE_STATS 패킷 꼬리에
259
+ // "LLM:count=N" 형태로 append 송신됨 (udp_session.js:161).
260
+
261
+ module.exports = {
262
+ LLMActiveStatTask,
263
+ LLMApiStatusTask,
264
+ LLMErrorStatTask,
265
+ LLMFeatureStatTask,
266
+ LLMPerfStatTask,
267
+ LLMTokenUsageTask,
268
+ };
@@ -275,7 +275,7 @@ DataOutputX.prototype.writeInt40BE = function(value) {
275
275
  return this.writeLong5(value);
276
276
  };
277
277
 
278
- // 새롭게 구현된 writeLong - Python 구현과 동일하게 작동
278
+ // 새롭게 구현된 writeLong
279
279
  DataOutputX.prototype.writeLong = function(value) {
280
280
  if (isNumOk(value) == false) {
281
281
  value = 0;
@@ -0,0 +1,35 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * BigInt 변환 캐싱 유틸.
5
+ * JS Number의 53bit 초과 정수 정밀도 손실 방지용 BigInt 변환은 비용이 있는 작업이므로
6
+ * 한 라이프사이클 내 동일 값에 대해 1회만 수행.
7
+ */
8
+
9
+ function bigStr(v) {
10
+ if (v == null) return '';
11
+ try {
12
+ return BigInt(v).toString();
13
+ } catch (e) {
14
+ return String(v);
15
+ }
16
+ }
17
+
18
+ /**
19
+ * ctx.id를 BigInt-precise string으로 반환. ctx에 _txidStr로 캐싱.
20
+ */
21
+ function ctxTxidStr(ctx) {
22
+ if (!ctx) return '';
23
+ if (ctx._txidStr) return ctx._txidStr;
24
+ ctx._txidStr = bigStr(ctx.id);
25
+ return ctx._txidStr;
26
+ }
27
+
28
+ /**
29
+ * 임의 step_id를 BigInt-precise string으로 반환. 라이프사이클 짧으면 캐싱 안 함.
30
+ */
31
+ function stepIdStr(stepIdRaw) {
32
+ return bigStr(stepIdRaw);
33
+ }
34
+
35
+ module.exports = { bigStr, ctxTxidStr, stepIdStr };