@omen.foundation/node-microservice-runtime 0.1.18 → 0.1.20

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.
@@ -0,0 +1,321 @@
1
+ import { spawn, ChildProcess } from 'child_process';
2
+ import { createWriteStream, existsSync, chmodSync, mkdirSync } from 'fs';
3
+ import { join } from 'path';
4
+ import { pipeline } from 'stream/promises';
5
+ import { createGunzip } from 'zlib';
6
+ import type { Logger } from 'pino';
7
+ import dgram from 'dgram';
8
+
9
+ interface CollectorDiscoveryEntry {
10
+ version: string;
11
+ status: string;
12
+ pid: number;
13
+ otlpEndpoint: string;
14
+ }
15
+
16
+ interface CollectorStatus {
17
+ isRunning: boolean;
18
+ isReady: boolean;
19
+ pid: number;
20
+ otlpEndpoint?: string;
21
+ version?: string;
22
+ }
23
+
24
+ const COLLECTOR_VERSION = '1.0.1'; // Match C# collector version (from collector-version.json)
25
+ const COLLECTOR_DOWNLOAD_BASE = `https://collectors.beamable.com/version/${COLLECTOR_VERSION}`;
26
+ const DISCOVERY_PORT = parseInt(process.env.BEAM_COLLECTOR_DISCOVERY_PORT || '8688', 10);
27
+ const DISCOVERY_DELAY = 100; // milliseconds
28
+ const DISCOVERY_ATTEMPTS = 10;
29
+
30
+ /**
31
+ * Gets the collector storage directory (similar to C# LocalApplicationData/beam/collectors/version)
32
+ */
33
+ function getCollectorStoragePath(): string {
34
+ // Use temp directory for now - in containers this should be writable
35
+ const tempDir = process.env.TMPDIR || process.env.TMP || '/tmp';
36
+ return join(tempDir, 'beam', 'collectors', COLLECTOR_VERSION);
37
+ }
38
+
39
+ /**
40
+ * Gets the collector binary name for the current platform
41
+ */
42
+ function getCollectorBinaryName(): string {
43
+ const platform = process.platform;
44
+ const arch = process.arch;
45
+
46
+ // Match C# naming: collector-{osArchSuffix}
47
+ // C# returns "collector-linux-amd64", not "beamable-collector-linux-amd64"
48
+ if (platform === 'linux' && arch === 'x64') {
49
+ return 'collector-linux-amd64';
50
+ } else if (platform === 'linux' && arch === 'arm64') {
51
+ return 'collector-linux-arm64';
52
+ } else if (platform === 'darwin' && arch === 'x64') {
53
+ return 'collector-darwin-amd64';
54
+ } else if (platform === 'darwin' && arch === 'arm64') {
55
+ return 'collector-darwin-arm64';
56
+ } else if (platform === 'win32' && arch === 'x64') {
57
+ return 'collector-windows-amd64.exe';
58
+ }
59
+
60
+ throw new Error(`Unsupported platform: ${platform} ${arch}`);
61
+ }
62
+
63
+ /**
64
+ * Downloads and decompresses a gzipped file
65
+ */
66
+ async function downloadAndDecompressGzip(url: string, outputPath: string, makeExecutable: boolean = false): Promise<void> {
67
+ const response = await fetch(url);
68
+ if (!response.ok) {
69
+ throw new Error(`Failed to download ${url}: ${response.statusText}`);
70
+ }
71
+
72
+ const dir = require('path').dirname(outputPath);
73
+ if (!existsSync(dir)) {
74
+ mkdirSync(dir, { recursive: true });
75
+ }
76
+
77
+ const gunzip = createGunzip();
78
+ const writeStream = createWriteStream(outputPath);
79
+
80
+ await pipeline(response.body as any, gunzip, writeStream);
81
+
82
+ if (makeExecutable && process.platform !== 'win32') {
83
+ try {
84
+ chmodSync(outputPath, 0o755);
85
+ } catch (error) {
86
+ console.error(`Failed to make ${outputPath} executable:`, error);
87
+ }
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Resolves the collector binary and config, downloading if needed
93
+ */
94
+ async function resolveCollector(allowDownload: boolean = true, logger?: Logger): Promise<{ binaryPath: string | null; configPath: string | null }> {
95
+ const basePath = getCollectorStoragePath();
96
+ const binaryName = getCollectorBinaryName();
97
+ const configName = 'clickhouse-config.yaml';
98
+
99
+ const binaryPath = join(basePath, binaryName);
100
+ const configPath = join(basePath, configName);
101
+
102
+ const itemsToDownload: Array<{ url: string; path: string; executable: boolean }> = [];
103
+
104
+ if (!existsSync(binaryPath) && allowDownload) {
105
+ const binaryUrl = `${COLLECTOR_DOWNLOAD_BASE}/${binaryName}.gz`;
106
+ itemsToDownload.push({ url: binaryUrl, path: binaryPath, executable: true });
107
+ logger?.info(`[Collector] Will download binary from: ${binaryUrl}`);
108
+ } else if (existsSync(binaryPath)) {
109
+ logger?.info(`[Collector] Binary found at: ${binaryPath}`);
110
+ }
111
+
112
+ if (!existsSync(configPath) && allowDownload) {
113
+ const configUrl = `${COLLECTOR_DOWNLOAD_BASE}/${configName}.gz`;
114
+ itemsToDownload.push({ url: configUrl, path: configPath, executable: false });
115
+ logger?.info(`[Collector] Will download config from: ${configUrl}`);
116
+ } else if (existsSync(configPath)) {
117
+ logger?.info(`[Collector] Config found at: ${configPath}`);
118
+ }
119
+
120
+ for (const item of itemsToDownload) {
121
+ logger?.info(`[Collector] Downloading ${item.url}...`);
122
+ await downloadAndDecompressGzip(item.url, item.path, item.executable);
123
+ logger?.info(`[Collector] Downloaded to ${item.path}`);
124
+ }
125
+
126
+ return {
127
+ binaryPath: existsSync(binaryPath) ? binaryPath : null,
128
+ configPath: existsSync(configPath) ? configPath : null,
129
+ };
130
+ }
131
+
132
+ /**
133
+ * Discovers collector via UDP broadcast
134
+ */
135
+ function discoverCollectorViaUDP(timeoutMs: number = 5000): Promise<CollectorDiscoveryEntry | null> {
136
+ return new Promise((resolve) => {
137
+ const socket = dgram.createSocket('udp4');
138
+ const discovered: CollectorDiscoveryEntry[] = [];
139
+ let timeout: NodeJS.Timeout;
140
+
141
+ socket.on('message', (msg) => {
142
+ try {
143
+ const message = JSON.parse(msg.toString()) as CollectorDiscoveryEntry;
144
+ // Check if version matches
145
+ if (message.version === COLLECTOR_VERSION && message.status === 'READY') {
146
+ discovered.push(message);
147
+ }
148
+ } catch (error) {
149
+ // Ignore parse errors
150
+ }
151
+ });
152
+
153
+ socket.on('error', () => {
154
+ clearTimeout(timeout);
155
+ socket.close();
156
+ resolve(null);
157
+ });
158
+
159
+ socket.bind(() => {
160
+ socket.setBroadcast(true);
161
+
162
+ timeout = setTimeout(() => {
163
+ socket.close();
164
+ // Return the first discovered collector
165
+ resolve(discovered.length > 0 ? discovered[0] : null);
166
+ }, timeoutMs);
167
+ });
168
+ });
169
+ }
170
+
171
+ /**
172
+ * Checks if collector is already running via UDP discovery
173
+ */
174
+ export async function isCollectorRunning(): Promise<CollectorStatus> {
175
+ try {
176
+ const discovered = await discoverCollectorViaUDP(2000);
177
+ if (discovered) {
178
+ return {
179
+ isRunning: true,
180
+ isReady: discovered.status === 'READY',
181
+ pid: discovered.pid,
182
+ otlpEndpoint: discovered.otlpEndpoint,
183
+ version: discovered.version,
184
+ };
185
+ }
186
+ } catch (error) {
187
+ // Discovery failed, collector probably not running
188
+ }
189
+
190
+ return {
191
+ isRunning: false,
192
+ isReady: false,
193
+ pid: 0,
194
+ };
195
+ }
196
+
197
+ /**
198
+ * Starts the OpenTelemetry collector process
199
+ */
200
+ export async function startCollector(
201
+ logger: Logger,
202
+ otlpEndpoint?: string
203
+ ): Promise<{ process: ChildProcess; endpoint: string }> {
204
+ logger.info('[Collector] Resolving collector binary and config...');
205
+ const collectorInfo = await resolveCollector(true, logger);
206
+
207
+ if (!collectorInfo.binaryPath) {
208
+ logger.error('[Collector] Binary not found and download failed');
209
+ throw new Error('Collector binary not found and download failed');
210
+ }
211
+
212
+ if (!collectorInfo.configPath) {
213
+ logger.error('[Collector] Config not found and download failed');
214
+ throw new Error('Collector config not found and download failed');
215
+ }
216
+
217
+ logger.info(`[Collector] Using binary: ${collectorInfo.binaryPath}`);
218
+ logger.info(`[Collector] Using config: ${collectorInfo.configPath}`);
219
+
220
+ // Determine OTLP endpoint
221
+ // Use a free port if not specified (like C# does with PortUtil.FreeEndpoint())
222
+ let localEndpoint = otlpEndpoint;
223
+ if (!localEndpoint) {
224
+ // For now, use default OTLP HTTP port
225
+ // In production, this would be discovered or set by Beamable
226
+ localEndpoint = '0.0.0.0:4318';
227
+ }
228
+ localEndpoint = localEndpoint.replace(/^http:\/\//, '').replace(/^https:\/\//, '');
229
+
230
+ // Set environment variables for collector
231
+ const env = {
232
+ ...process.env,
233
+ BEAM_OTLP_HTTP_ENDPOINT: localEndpoint,
234
+ BEAM_CLICKHOUSE_ENDPOINT: process.env.BEAM_CLICKHOUSE_ENDPOINT || '',
235
+ BEAM_CLICKHOUSE_USERNAME: process.env.BEAM_CLICKHOUSE_USERNAME || '',
236
+ BEAM_CLICKHOUSE_PASSWORD: process.env.BEAM_CLICKHOUSE_PASSWORD || '',
237
+ BEAM_COLLECTOR_DISCOVERY_PORT: String(DISCOVERY_PORT),
238
+ BEAM_CLICKHOUSE_PROCESSOR_TIMEOUT: process.env.BEAM_CLICKHOUSE_PROCESSOR_TIMEOUT || '5s',
239
+ BEAM_CLICKHOUSE_PROCESSOR_BATCH_SIZE: process.env.BEAM_CLICKHOUSE_PROCESSOR_BATCH_SIZE || '5000',
240
+ BEAM_CLICKHOUSE_EXPORTER_TIMEOUT: process.env.BEAM_CLICKHOUSE_EXPORTER_TIMEOUT || '5s',
241
+ BEAM_CLICKHOUSE_EXPORTER_QUEUE_SIZE: process.env.BEAM_CLICKHOUSE_EXPORTER_QUEUE_SIZE || '1000',
242
+ BEAM_CLICKHOUSE_EXPORTER_RETRY_ENABLED: process.env.BEAM_CLICKHOUSE_EXPORTER_RETRY_ENABLED || 'true',
243
+ BEAM_CLICKHOUSE_EXPORTER_RETRY_INITIAL_INTERVAL: process.env.BEAM_CLICKHOUSE_EXPORTER_RETRY_INITIAL_INTERVAL || '5s',
244
+ BEAM_CLICKHOUSE_EXPORTER_RETRY_MAX_INTERVAL: process.env.BEAM_CLICKHOUSE_EXPORTER_RETRY_MAX_INTERVAL || '30s',
245
+ BEAM_CLICKHOUSE_EXPORTER_RETRY_MAX_ELAPSED_TIME: process.env.BEAM_CLICKHOUSE_EXPORTER_RETRY_MAX_ELAPSED_TIME || '300s',
246
+ };
247
+
248
+ // Start collector process
249
+ const collectorProcess = spawn(collectorInfo.binaryPath, ['--config', collectorInfo.configPath], {
250
+ env,
251
+ stdio: ['ignore', 'pipe', 'pipe'],
252
+ detached: false,
253
+ });
254
+
255
+ collectorProcess.stdout?.on('data', (data) => {
256
+ logger.debug(`[Collector] ${data.toString().trim()}`);
257
+ });
258
+
259
+ collectorProcess.stderr?.on('data', (data) => {
260
+ logger.debug(`[Collector ERR] ${data.toString().trim()}`);
261
+ });
262
+
263
+ collectorProcess.on('error', (err) => {
264
+ logger.error(`[Collector] Failed to start: ${err.message}`);
265
+ });
266
+
267
+ collectorProcess.on('exit', (code) => {
268
+ logger.warn(`[Collector] Process exited with code ${code}`);
269
+ });
270
+
271
+ logger.info(`[Collector] Started with PID ${collectorProcess.pid}, endpoint: ${localEndpoint}`);
272
+
273
+ return {
274
+ process: collectorProcess,
275
+ endpoint: `http://${localEndpoint}`,
276
+ };
277
+ }
278
+
279
+ /**
280
+ * Discovers or starts the collector and returns the OTLP endpoint
281
+ */
282
+ export async function discoverOrStartCollector(
283
+ logger: Logger,
284
+ standardOtelEnabled: boolean
285
+ ): Promise<string | null> {
286
+ if (!standardOtelEnabled) {
287
+ return null;
288
+ }
289
+
290
+ // First, check if collector is already running
291
+ const status = await isCollectorRunning();
292
+ if (status.isRunning && status.isReady && status.otlpEndpoint) {
293
+ logger.info(`[Collector] Found running collector at ${status.otlpEndpoint}`);
294
+ return `http://${status.otlpEndpoint}`;
295
+ }
296
+
297
+ // Collector not running - start it
298
+ try {
299
+ logger.info('[Collector] Starting OpenTelemetry collector...');
300
+ const { endpoint } = await startCollector(logger);
301
+
302
+ // Wait a bit for collector to start and become ready
303
+ // Try to discover it
304
+ for (let i = 0; i < DISCOVERY_ATTEMPTS; i++) {
305
+ await new Promise(resolve => setTimeout(resolve, DISCOVERY_DELAY));
306
+ const newStatus = await isCollectorRunning();
307
+ if (newStatus.isRunning && newStatus.isReady) {
308
+ logger.info(`[Collector] Collector is ready at ${newStatus.otlpEndpoint || endpoint}`);
309
+ return newStatus.otlpEndpoint ? `http://${newStatus.otlpEndpoint}` : endpoint;
310
+ }
311
+ }
312
+
313
+ // Return the endpoint we started with, even if discovery didn't find it yet
314
+ logger.warn('[Collector] Collector started but not yet ready, using configured endpoint');
315
+ return endpoint;
316
+ } catch (err) {
317
+ logger.error(`[Collector] Failed to start collector: ${err instanceof Error ? err.message : String(err)}`);
318
+ return null;
319
+ }
320
+ }
321
+
package/src/logger.ts CHANGED
@@ -7,6 +7,8 @@ import { logs } from '@opentelemetry/api-logs';
7
7
  import { LoggerProvider, SimpleLogRecordProcessor } from '@opentelemetry/sdk-logs';
8
8
  import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-http';
9
9
  import { resourceFromAttributes, defaultResource } from '@opentelemetry/resources';
10
+ import { discoverOrStartCollector } from './collector-manager.js';
11
+ import type { Logger as PinoLogger } from 'pino';
10
12
 
11
13
  // Helper to get require function that works in both CJS and ESM
12
14
  declare const require: any;
@@ -73,11 +75,12 @@ function mapPinoLevelToBeamableLevel(level: number): string {
73
75
  * @param env - Environment configuration
74
76
  * @returns OTLP logger provider if configured, null otherwise
75
77
  */
76
- function initializeOtlpLogging(
78
+ async function initializeOtlpLogging(
77
79
  serviceName?: string,
78
80
  qualifiedServiceName?: string,
79
- env?: EnvironmentConfig
80
- ): LoggerProvider | null {
81
+ env?: EnvironmentConfig,
82
+ logger?: PinoLogger
83
+ ): Promise<LoggerProvider | null> {
81
84
  // Check for explicit OTLP endpoint (same as C#: BEAM_OTEL_EXPORTER_OTLP_ENDPOINT)
82
85
  // Also check standard OTEL environment variables
83
86
  const otlpEndpoint = process.env.BEAM_OTEL_EXPORTER_OTLP_ENDPOINT
@@ -92,7 +95,7 @@ function initializeOtlpLogging(
92
95
 
93
96
  // If no explicit endpoint and standard OTLP not enabled, skip OTLP
94
97
  if (!otlpEndpoint && !standardOtelEnabled) {
95
- return null;
98
+ return Promise.resolve(null);
96
99
  }
97
100
 
98
101
  try {
@@ -117,19 +120,31 @@ function initializeOtlpLogging(
117
120
 
118
121
  // Determine endpoint URL
119
122
  // If explicit endpoint provided, use it (append /v1/logs if needed for HTTP)
120
- // Note: C# microservices discover the collector endpoint via socket communication or start a collector
121
- // For Node.js, we require an explicit endpoint to be configured - we don't assume localhost
122
- // The OTLP endpoint should be provided via BEAM_OTEL_EXPORTER_OTLP_ENDPOINT environment variable
123
+ // If standard OTLP enabled but no explicit endpoint, try to discover or start collector
123
124
  let endpointUrl = otlpEndpoint;
124
125
 
125
- // If no explicit endpoint is provided, we cannot use OTLP
126
- // C# microservices can discover/start collectors, but Node.js requires explicit configuration
126
+ if (!endpointUrl && standardOtelEnabled && logger) {
127
+ // Try to discover existing collector or start a new one (like C# does)
128
+ try {
129
+ const discoveredEndpoint = await discoverOrStartCollector(logger, standardOtelEnabled);
130
+ if (discoveredEndpoint) {
131
+ endpointUrl = discoveredEndpoint;
132
+ } else {
133
+ console.error('[OTLP] Standard OTLP is enabled but could not discover or start collector.');
134
+ return Promise.resolve(null);
135
+ }
136
+ } catch (error) {
137
+ console.error('[OTLP] Failed to discover/start collector:', error instanceof Error ? error.message : String(error));
138
+ return Promise.resolve(null);
139
+ }
140
+ }
141
+
142
+ // If still no endpoint, skip OTLP
127
143
  if (!endpointUrl) {
128
144
  if (standardOtelEnabled) {
129
- // Log that OTLP is expected but endpoint not configured
130
- console.error('[OTLP] Standard OTLP is enabled but no endpoint configured. Set BEAM_OTEL_EXPORTER_OTLP_ENDPOINT to enable OTLP logging.');
145
+ console.error('[OTLP] Standard OTLP is enabled but no endpoint available. Set BEAM_OTEL_EXPORTER_OTLP_ENDPOINT to enable OTLP logging.');
131
146
  }
132
- return null;
147
+ return Promise.resolve(null);
133
148
  }
134
149
 
135
150
  // Ensure endpoint has /v1/logs suffix for HTTP exporter (like C# does)
@@ -167,7 +182,7 @@ function initializeOtlpLogging(
167
182
  // This helps diagnose if OTLP is working in deployed environments
168
183
  console.error(`[OTLP] OpenTelemetry logging initialized. Endpoint: ${finalEndpointUrl}, Service: ${serviceName || 'unknown'}`);
169
184
 
170
- return loggerProvider;
185
+ return Promise.resolve(loggerProvider);
171
186
  } catch (error) {
172
187
  // If OTLP initialization fails, log error but continue without OTLP
173
188
  // Don't throw - we still want stdout logging to work
@@ -176,7 +191,7 @@ function initializeOtlpLogging(
176
191
  if (error instanceof Error && error.stack) {
177
192
  console.error('[OTLP] Stack trace:', error.stack);
178
193
  }
179
- return null;
194
+ return Promise.resolve(null);
180
195
  }
181
196
  }
182
197
 
@@ -191,7 +206,7 @@ function initializeOtlpLogging(
191
206
  function createBeamableLogFormatter(
192
207
  serviceName?: string,
193
208
  qualifiedServiceName?: string,
194
- otlpLoggerProvider?: LoggerProvider | null
209
+ otlpProviderRef?: { provider: LoggerProvider | null }
195
210
  ): Transform {
196
211
  return new Transform({
197
212
  objectMode: false, // Pino writes strings/Buffers, not objects
@@ -362,9 +377,10 @@ function createBeamableLogFormatter(
362
377
  Object.assign(beamableLog, otelFields);
363
378
 
364
379
  // Send log via OTLP if configured (similar to C# MicroserviceOtelLogRecordExporter)
365
- if (otlpLoggerProvider) {
380
+ // Check if provider is available (may be null if still initializing)
381
+ if (otlpProviderRef?.provider) {
366
382
  try {
367
- const otlpLogger = otlpLoggerProvider.getLogger(
383
+ const otlpLogger = otlpProviderRef.provider.getLogger(
368
384
  serviceName || 'beamable-node-runtime',
369
385
  undefined, // version
370
386
  {
@@ -422,13 +438,38 @@ export function createLogger(env: EnvironmentConfig, options: LoggerFactoryOptio
422
438
  const configuredDestination = options.destinationPath ?? process.env.LOG_PATH;
423
439
  const usePrettyLogs = shouldUsePrettyLogs();
424
440
 
425
- // Initialize OTLP logging if configured (similar to C# microservices)
441
+ // Shared reference for OTLP logger provider (updated asynchronously)
442
+ const otlpProviderRef: { provider: LoggerProvider | null } = { provider: null };
443
+
444
+ // Initialize OTLP logging in the background (similar to C# microservices)
426
445
  // This sends logs via OpenTelemetry Protocol in addition to stdout JSON
427
- const otlpLoggerProvider = initializeOtlpLogging(
428
- options.serviceName,
429
- options.qualifiedServiceName,
430
- env
431
- );
446
+ // We start this async so it doesn't block logger creation
447
+ const isInDocker = process.env.IS_LOCAL !== '1' && process.env.IS_LOCAL !== 'true';
448
+ const standardOtelEnabled = isInDocker && !process.env.BEAM_DISABLE_STANDARD_OTEL;
449
+
450
+ // Start collector discovery/start in background
451
+ if (standardOtelEnabled || process.env.BEAM_OTEL_EXPORTER_OTLP_ENDPOINT) {
452
+ // Create a temporary logger for OTLP initialization
453
+ const tempLogger = pino({
454
+ name: options.name ?? 'beamable-node-runtime',
455
+ level: env.logLevel,
456
+ }, process.stdout);
457
+
458
+ // Initialize OTLP asynchronously (fire-and-forget)
459
+ initializeOtlpLogging(
460
+ options.serviceName,
461
+ options.qualifiedServiceName,
462
+ env,
463
+ tempLogger
464
+ ).then((provider) => {
465
+ otlpProviderRef.provider = provider;
466
+ if (provider) {
467
+ tempLogger.info('[OTLP] OpenTelemetry logging initialized successfully');
468
+ }
469
+ }).catch((error) => {
470
+ tempLogger.error(`[OTLP] Failed to initialize: ${error instanceof Error ? error.message : String(error)}`);
471
+ });
472
+ }
432
473
 
433
474
  const pinoOptions: LoggerOptions = {
434
475
  name: options.name ?? 'beamable-node-runtime',
@@ -460,7 +501,7 @@ export function createLogger(env: EnvironmentConfig, options: LoggerFactoryOptio
460
501
  const beamableFormatter = createBeamableLogFormatter(
461
502
  options.serviceName,
462
503
  options.qualifiedServiceName,
463
- otlpLoggerProvider
504
+ otlpProviderRef
464
505
  );
465
506
  beamableFormatter.pipe(process.stdout);
466
507
  return pino(pinoOptions, beamableFormatter);
@@ -496,7 +537,7 @@ export function createLogger(env: EnvironmentConfig, options: LoggerFactoryOptio
496
537
  const beamableFormatter = createBeamableLogFormatter(
497
538
  options.serviceName,
498
539
  options.qualifiedServiceName,
499
- otlpLoggerProvider
540
+ otlpProviderRef
500
541
  );
501
542
  const fileStream = destination({ dest: resolvedDestination, mkdir: true, append: true, sync: false });
502
543
  beamableFormatter.pipe(fileStream as unknown as NodeJS.WritableStream);