@omen.foundation/node-microservice-runtime 0.1.25 → 0.1.27

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.
@@ -5,6 +5,8 @@ import { pipeline } from 'stream/promises';
5
5
  import { createGunzip } from 'zlib';
6
6
  import type { Logger } from 'pino';
7
7
  import dgram from 'dgram';
8
+ import type { EnvironmentConfig } from './types.js';
9
+ import { hostToHttpUrl } from './utils/urls.js';
8
10
 
9
11
  // Protocol is httpprotobuf
10
12
 
@@ -15,7 +17,7 @@ interface CollectorDiscoveryEntry {
15
17
  otlpEndpoint: string;
16
18
  }
17
19
 
18
- interface CollectorStatus {
20
+ export interface CollectorStatus {
19
21
  isRunning: boolean;
20
22
  isReady: boolean;
21
23
  pid: number;
@@ -29,6 +31,62 @@ const DISCOVERY_PORT = parseInt(process.env.BEAM_COLLECTOR_DISCOVERY_PORT || '86
29
31
  const DISCOVERY_DELAY = 100; // milliseconds
30
32
  const DISCOVERY_ATTEMPTS = 10;
31
33
 
34
+ /**
35
+ * ClickHouse credentials response from Beamable API
36
+ */
37
+ interface ClickHouseCredentials {
38
+ endpoint: string;
39
+ expiresAt?: string;
40
+ password: string;
41
+ username: string;
42
+ }
43
+
44
+ /**
45
+ * Fetches ClickHouse credentials from Beamable API
46
+ * GET /api/beamo/otel/auth/writer/config
47
+ */
48
+ export async function fetchClickHouseCredentials(
49
+ env: EnvironmentConfig,
50
+ logger: Logger
51
+ ): Promise<ClickHouseCredentials> {
52
+ const apiUrl = hostToHttpUrl(env.host);
53
+ const configUrl = new URL('/api/beamo/otel/auth/writer/config', apiUrl).toString();
54
+
55
+ logger.info('[Collector] Fetching ClickHouse credentials from Beamable API...');
56
+
57
+ // Build headers with authentication
58
+ const headers: Record<string, string> = {
59
+ 'Content-Type': 'application/json',
60
+ Accept: 'application/json',
61
+ 'beam-scope': `${env.cid}.${env.pid}`,
62
+ };
63
+
64
+ // If we have a secret, we might need to sign the request
65
+ // For now, try without signature (the API might use the beam-scope header for auth)
66
+ const response = await fetch(configUrl, {
67
+ method: 'GET',
68
+ headers,
69
+ });
70
+
71
+ if (!response.ok) {
72
+ const errorText = await response.text().catch(() => 'Unknown error');
73
+ throw new Error(`Failed to fetch ClickHouse credentials: ${response.status} ${response.statusText} - ${errorText}`);
74
+ }
75
+
76
+ const credentials = await response.json() as ClickHouseCredentials;
77
+
78
+ if (!credentials.endpoint || !credentials.username || !credentials.password) {
79
+ throw new Error('Invalid ClickHouse credentials response: missing required fields');
80
+ }
81
+
82
+ logger.info('[Collector] Successfully fetched ClickHouse credentials');
83
+ if (credentials.expiresAt) {
84
+ logger.debug(`[Collector] Credentials expire at: ${credentials.expiresAt}`);
85
+ }
86
+
87
+ return credentials;
88
+ }
89
+
32
90
  /**
33
91
  * Gets the collector storage directory (similar to C# LocalApplicationData/beam/collectors/version)
34
92
  */
@@ -170,6 +228,36 @@ function discoverCollectorViaUDP(timeoutMs: number = 5000): Promise<CollectorDis
170
228
  });
171
229
  }
172
230
 
231
+ /**
232
+ * Gets the current status of ClickHouse credentials (from env or API)
233
+ */
234
+ export function getClickHouseCredentialsStatus(): {
235
+ hasEndpoint: boolean;
236
+ hasUsername: boolean;
237
+ hasPassword: boolean;
238
+ source: 'environment' | 'api' | 'missing';
239
+ } {
240
+ const hasEndpoint = !!process.env.BEAM_CLICKHOUSE_ENDPOINT;
241
+ const hasUsername = !!process.env.BEAM_CLICKHOUSE_USERNAME;
242
+ const hasPassword = !!process.env.BEAM_CLICKHOUSE_PASSWORD;
243
+
244
+ if (hasEndpoint && hasUsername && hasPassword) {
245
+ return {
246
+ hasEndpoint: true,
247
+ hasUsername: true,
248
+ hasPassword: true,
249
+ source: 'environment',
250
+ };
251
+ }
252
+
253
+ return {
254
+ hasEndpoint,
255
+ hasUsername,
256
+ hasPassword,
257
+ source: 'missing',
258
+ };
259
+ }
260
+
173
261
  /**
174
262
  * Checks if collector is already running via UDP discovery
175
263
  */
@@ -201,7 +289,8 @@ export async function isCollectorRunning(): Promise<CollectorStatus> {
201
289
  */
202
290
  export async function startCollector(
203
291
  logger: Logger,
204
- otlpEndpoint?: string
292
+ otlpEndpoint?: string,
293
+ env?: EnvironmentConfig
205
294
  ): Promise<{ process: ChildProcess; endpoint: string }> {
206
295
  logger.info('[Collector] Resolving collector binary and config...');
207
296
  const collectorInfo = await resolveCollector(true, logger);
@@ -219,20 +308,34 @@ export async function startCollector(
219
308
  logger.info(`[Collector] Using binary: ${collectorInfo.binaryPath}`);
220
309
  logger.info(`[Collector] Using config: ${collectorInfo.configPath}`);
221
310
 
222
- // Validate required environment variables (matching C# behavior)
223
- // These must be set before starting the collector, otherwise it will fail
224
- const requiredEnvVars = {
225
- BEAM_CLICKHOUSE_ENDPOINT: process.env.BEAM_CLICKHOUSE_ENDPOINT,
226
- BEAM_CLICKHOUSE_USERNAME: process.env.BEAM_CLICKHOUSE_USERNAME,
227
- BEAM_CLICKHOUSE_PASSWORD: process.env.BEAM_CLICKHOUSE_PASSWORD,
228
- };
311
+ // Fetch ClickHouse credentials from Beamable API if not in environment variables
312
+ // Per Gabriel: "these OTEL related ones you need to do it yourself at the beginning of the microservice startup"
313
+ let clickhouseEndpoint = process.env.BEAM_CLICKHOUSE_ENDPOINT;
314
+ let clickhouseUsername = process.env.BEAM_CLICKHOUSE_USERNAME;
315
+ let clickhousePassword = process.env.BEAM_CLICKHOUSE_PASSWORD;
229
316
 
230
- const missingVars = Object.entries(requiredEnvVars)
231
- .filter(([_, value]) => !value || value.trim() === '')
232
- .map(([key]) => key);
317
+ if ((!clickhouseEndpoint || !clickhouseUsername || !clickhousePassword) && env) {
318
+ try {
319
+ const credentials = await fetchClickHouseCredentials(env, logger);
320
+ clickhouseEndpoint = credentials.endpoint;
321
+ clickhouseUsername = credentials.username;
322
+ clickhousePassword = credentials.password;
323
+
324
+ // Set them in process.env so the collector can access them
325
+ process.env.BEAM_CLICKHOUSE_ENDPOINT = clickhouseEndpoint;
326
+ process.env.BEAM_CLICKHOUSE_USERNAME = clickhouseUsername;
327
+ process.env.BEAM_CLICKHOUSE_PASSWORD = clickhousePassword;
328
+ } catch (error) {
329
+ const errorMsg = `[Collector] Failed to fetch ClickHouse credentials from API: ${error instanceof Error ? error.message : String(error)}`;
330
+ logger.error(errorMsg);
331
+ throw new Error(errorMsg);
332
+ }
333
+ }
233
334
 
234
- if (missingVars.length > 0) {
235
- const errorMsg = `[Collector] Required environment variables are missing or empty: ${missingVars.join(', ')}. Collector will fail to start without these.`;
335
+ // Validate required environment variables (matching C# behavior)
336
+ // These must be set before starting the collector, otherwise it will fail
337
+ if (!clickhouseEndpoint || !clickhouseUsername || !clickhousePassword) {
338
+ const errorMsg = `[Collector] Required ClickHouse credentials are missing. Set BEAM_CLICKHOUSE_ENDPOINT, BEAM_CLICKHOUSE_USERNAME, and BEAM_CLICKHOUSE_PASSWORD, or ensure the API endpoint is accessible.`;
236
339
  logger.error(errorMsg);
237
340
  throw new Error(errorMsg);
238
341
  }
@@ -250,12 +353,12 @@ export async function startCollector(
250
353
  // Set environment variables for collector
251
354
  // Note: BEAM_CLICKHOUSE_ENDPOINT is for collector → ClickHouse communication
252
355
  // This is different from the OTLP endpoint (microservice → collector)
253
- const env = {
356
+ const collectorEnv = {
254
357
  ...process.env,
255
358
  BEAM_OTLP_HTTP_ENDPOINT: localEndpoint,
256
- BEAM_CLICKHOUSE_ENDPOINT: requiredEnvVars.BEAM_CLICKHOUSE_ENDPOINT!,
257
- BEAM_CLICKHOUSE_USERNAME: requiredEnvVars.BEAM_CLICKHOUSE_USERNAME!,
258
- BEAM_CLICKHOUSE_PASSWORD: requiredEnvVars.BEAM_CLICKHOUSE_PASSWORD!,
359
+ BEAM_CLICKHOUSE_ENDPOINT: clickhouseEndpoint!,
360
+ BEAM_CLICKHOUSE_USERNAME: clickhouseUsername!,
361
+ BEAM_CLICKHOUSE_PASSWORD: clickhousePassword!,
259
362
  BEAM_COLLECTOR_DISCOVERY_PORT: String(DISCOVERY_PORT),
260
363
  BEAM_CLICKHOUSE_PROCESSOR_TIMEOUT: process.env.BEAM_CLICKHOUSE_PROCESSOR_TIMEOUT || '5s',
261
364
  BEAM_CLICKHOUSE_PROCESSOR_BATCH_SIZE: process.env.BEAM_CLICKHOUSE_PROCESSOR_BATCH_SIZE || '5000',
@@ -269,7 +372,7 @@ export async function startCollector(
269
372
 
270
373
  // Start collector process
271
374
  const collectorProcess = spawn(collectorInfo.binaryPath, ['--config', collectorInfo.configPath], {
272
- env,
375
+ env: collectorEnv,
273
376
  stdio: ['ignore', 'pipe', 'pipe'],
274
377
  detached: false,
275
378
  });
@@ -303,7 +406,8 @@ export async function startCollector(
303
406
  */
304
407
  export async function discoverOrStartCollector(
305
408
  logger: Logger,
306
- standardOtelEnabled: boolean
409
+ standardOtelEnabled: boolean,
410
+ env?: EnvironmentConfig
307
411
  ): Promise<string | null> {
308
412
  if (!standardOtelEnabled) {
309
413
  return null;
@@ -319,7 +423,7 @@ export async function discoverOrStartCollector(
319
423
  // Collector not running - start it
320
424
  try {
321
425
  logger.info('[Collector] Starting OpenTelemetry collector...');
322
- const { endpoint } = await startCollector(logger);
426
+ const { endpoint } = await startCollector(logger, undefined, env);
323
427
 
324
428
  // Wait a bit for collector to start and become ready
325
429
  // Try to discover it
package/src/index.ts CHANGED
@@ -49,3 +49,8 @@ export {
49
49
  type FederatedItemProperty,
50
50
  type FederatedItemProxy,
51
51
  } from './federation.js';
52
+ export {
53
+ isCollectorRunning,
54
+ getClickHouseCredentialsStatus,
55
+ type CollectorStatus,
56
+ } from './collector-manager.js';
package/src/logger.ts CHANGED
@@ -135,7 +135,7 @@ async function initializeOtlpLogging(
135
135
  if (!endpointUrl && standardOtelEnabled && logger) {
136
136
  // Try to discover existing collector or start a new one (like C# does)
137
137
  try {
138
- const discoveredEndpoint = await discoverOrStartCollector(logger, standardOtelEnabled);
138
+ const discoveredEndpoint = await discoverOrStartCollector(logger, standardOtelEnabled, env);
139
139
  if (discoveredEndpoint) {
140
140
  endpointUrl = discoveredEndpoint;
141
141
  } else {
@@ -252,6 +252,31 @@ async function initializeOtlpLogging(
252
252
  // This helps diagnose if OTLP is working in deployed environments
253
253
  console.error(`[OTLP] OpenTelemetry logging initialized. Endpoint: ${finalEndpointUrl}, Service: ${serviceName || 'unknown'}`);
254
254
 
255
+ // CRITICAL: Verify collector is actually ready before returning
256
+ // If we started a collector, wait for it to be ready (up to 5 seconds)
257
+ if (standardOtelEnabled && logger) {
258
+ try {
259
+ const { isCollectorRunning } = await import('./collector-manager.js');
260
+ // Wait up to 5 seconds for collector to be ready
261
+ for (let i = 0; i < 50; i++) {
262
+ const status = await isCollectorRunning();
263
+ if (status.isRunning && status.isReady) {
264
+ if (logger) {
265
+ logger.info(`[OTLP] Collector confirmed ready at ${status.otlpEndpoint || finalEndpointUrl}`);
266
+ }
267
+ break;
268
+ }
269
+ // Wait 100ms between checks
270
+ await new Promise(resolve => setTimeout(resolve, 100));
271
+ }
272
+ } catch (error) {
273
+ // If we can't verify, log but continue
274
+ if (logger) {
275
+ logger.warn(`[OTLP] Could not verify collector readiness: ${error instanceof Error ? error.message : String(error)}`);
276
+ }
277
+ }
278
+ }
279
+
255
280
  return Promise.resolve(loggerProvider);
256
281
  } catch (error) {
257
282
  // If OTLP initialization fails, log error but continue without OTLP
@@ -517,7 +542,7 @@ function initializeOtlpSync(
517
542
  serviceName?: string,
518
543
  qualifiedServiceName?: string,
519
544
  env?: EnvironmentConfig,
520
- timeoutMs: number = 30000 // Increased to 30 seconds to allow collector download
545
+ timeoutMs: number = 60000 // 60 seconds to allow collector download, startup, and readiness check
521
546
  ): LoggerProvider | null {
522
547
  // Match C# logic: (this.InDocker() || UseLocalOtel) && !BEAM_DISABLE_STANDARD_OTEL
523
548
  const isInDocker = process.env.IS_LOCAL !== '1' && process.env.IS_LOCAL !== 'true';
@@ -575,14 +600,20 @@ function initializeOtlpSync(
575
600
  // Wait synchronously for the promise to resolve using deasync
576
601
  // deasync.loopWhile() allows event loop to process while we wait
577
602
  // This enables async operations (like collector download) to run and complete
603
+ // CRITICAL: This must complete before the logger is created to capture all startup logs
578
604
  try {
579
605
  // Use deasync to wait for completion, with timeout check
606
+ // loopWhile returns false to stop waiting, true to continue
580
607
  deasync.loopWhile(() => {
581
608
  // Check if we've exceeded timeout
582
- if (Date.now() - startTime >= timeoutMs) {
609
+ const elapsed = Date.now() - startTime;
610
+ if (elapsed >= timeoutMs) {
611
+ initLogger.warn(`[OTLP] Synchronous wait timeout after ${elapsed}ms`);
583
612
  return false; // Stop waiting
584
613
  }
585
- return !completed; // Continue waiting if not completed
614
+ // Continue waiting if not completed
615
+ // This allows the event loop to process async operations
616
+ return !completed;
586
617
  });
587
618
  } catch (error) {
588
619
  // If deasync fails, log and continue
@@ -591,9 +622,14 @@ function initializeOtlpSync(
591
622
 
592
623
  clearTimeout(timeoutId);
593
624
 
594
- // If we timed out, log a warning but continue
595
- if (!completed) {
625
+ // Verify we got a provider (collector is ready)
626
+ if (completed && provider) {
627
+ initLogger.info('[OTLP] Initialization completed successfully, collector is ready');
628
+ } else if (!completed) {
596
629
  initLogger.warn('[OTLP] Initialization did not complete in time, logs may not be sent via OTLP initially');
630
+ initLogger.warn('[OTLP] Service will continue without OTLP telemetry - requests will still be serviced');
631
+ } else {
632
+ initLogger.warn('[OTLP] Initialization completed but no provider returned, OTLP telemetry disabled');
597
633
  }
598
634
 
599
635
  return provider;
@@ -606,11 +642,12 @@ export function createLogger(env: EnvironmentConfig, options: LoggerFactoryOptio
606
642
  // Initialize OTLP synchronously BEFORE creating the logger
607
643
  // This ensures all logs from this point forward are captured via OTLP
608
644
  // Similar to C# microservices which configure logging early in startup
645
+ // CRITICAL: We wait for collector to be fully initialized and ready before proceeding
609
646
  const otlpProvider = initializeOtlpSync(
610
647
  options.serviceName,
611
648
  options.qualifiedServiceName,
612
649
  env,
613
- 30000 // 30 second timeout to allow collector download and startup
650
+ 60000 // 60 second timeout to allow collector download, startup, and readiness verification
614
651
  );
615
652
 
616
653
  // Shared reference for OTLP logger provider