@omen.foundation/node-microservice-runtime 0.1.65 → 0.1.67

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/src/logger.ts DELETED
@@ -1,727 +0,0 @@
1
- import pino, { destination, type Logger, type LoggerOptions } from 'pino';
2
- import { Transform } from 'node:stream';
3
- import { createRequire } from 'node:module';
4
- import { ensureWritableTempDirectory } from './env.js';
5
- import type { EnvironmentConfig } from './types.js';
6
- import { logs } from '@opentelemetry/api-logs';
7
- import { LoggerProvider, SimpleLogRecordProcessor } from '@opentelemetry/sdk-logs';
8
- import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-http';
9
- import { resourceFromAttributes, defaultResource } from '@opentelemetry/resources';
10
- import { discoverOrStartCollector } from './collector-manager.js';
11
- import type { Logger as PinoLogger } from 'pino';
12
-
13
- // Helper to get require function that works in both CJS and ESM
14
- declare const require: any;
15
- function getRequire(): any {
16
- // Check if we're in CJS context (require.main exists)
17
- if (typeof require !== 'undefined' && typeof require.main !== 'undefined') {
18
- // CJS context - use require directly
19
- return require;
20
- }
21
- // ESM context - use createRequire with import.meta.url
22
- // TypeScript will complain in CJS builds, but this code only runs in ESM
23
- // @ts-ignore - import.meta is ESM-only, TypeScript error in CJS is expected
24
- return createRequire(import.meta.url);
25
- }
26
-
27
- /**
28
- * Determines if we should use pretty logs (local dev) or raw JSON logs (deployed).
29
- *
30
- * Simple check: If IS_LOCAL=1 is set in environment, use pretty logs.
31
- * Otherwise, use raw Beamable JSON format for log collection.
32
- */
33
- function shouldUsePrettyLogs(): boolean {
34
- // Check for explicit IS_LOCAL flag
35
- return process.env.IS_LOCAL === '1' || process.env.IS_LOCAL === 'true';
36
- }
37
-
38
- interface LoggerFactoryOptions {
39
- name?: string;
40
- destinationPath?: string;
41
- serviceName?: string; // Service name for log filtering (e.g., "ExampleNodeService")
42
- qualifiedServiceName?: string; // Full qualified service name (e.g., "micro_ExampleNodeService")
43
- otlpEndpoint?: string; // OTLP endpoint if collector is already set up (avoids re-discovery)
44
- }
45
-
46
- /**
47
- * Maps Pino log levels to Beamable log levels
48
- * Pino levels: 10=trace, 20=debug, 30=info, 40=warn, 50=error, 60=fatal
49
- * Beamable levels: Debug, Info, Warning, Error, Fatal
50
- */
51
- function mapPinoLevelToBeamableLevel(level: number): string {
52
- switch (level) {
53
- case 10: // trace
54
- return 'Debug';
55
- case 20: // debug
56
- return 'Debug';
57
- case 30: // info
58
- return 'Info';
59
- case 40: // warn
60
- return 'Warning';
61
- case 50: // error
62
- return 'Error';
63
- case 60: // fatal
64
- return 'Fatal';
65
- default:
66
- return 'Info';
67
- }
68
- }
69
-
70
- /**
71
- * Initializes OpenTelemetry OTLP log exporter if configured.
72
- * Similar to C# microservices, checks for BEAM_OTEL_EXPORTER_OTLP_ENDPOINT or uses standard enabled flag.
73
- *
74
- * @param serviceName - Service name for resource attributes
75
- * @param qualifiedServiceName - Qualified service name for resource attributes
76
- * @param env - Environment configuration
77
- * @returns OTLP logger provider if configured, null otherwise
78
- */
79
- async function initializeOtlpLogging(
80
- serviceName?: string,
81
- qualifiedServiceName?: string,
82
- env?: EnvironmentConfig,
83
- logger?: PinoLogger
84
- ): Promise<LoggerProvider | null> {
85
- // Check for explicit OTLP endpoint (same as C#: BEAM_OTEL_EXPORTER_OTLP_ENDPOINT)
86
- // Also check standard OTEL environment variables
87
- const otlpEndpoint = process.env.BEAM_OTEL_EXPORTER_OTLP_ENDPOINT
88
- || process.env.OTEL_EXPORTER_OTLP_ENDPOINT
89
- || (process.env.OTEL_EXPORTER_OTLP_ENDPOINT_LOGS ? process.env.OTEL_EXPORTER_OTLP_ENDPOINT_LOGS : null);
90
-
91
- // Check for explicit OTLP protocol (same as C#: OtelExporterOtlpProtocol)
92
- // C# uses OtlpExportProtocol enum: HttpProtobuf or HttpJson
93
- const otlpProtocol = process.env.BEAM_OTEL_EXPORTER_OTLP_PROTOCOL
94
- || process.env.OTEL_EXPORTER_OTLP_PROTOCOL
95
- || process.env.OTEL_EXPORTER_OTLP_PROTOCOL_LOGS;
96
-
97
- // Check if standard OTLP is enabled (matching C# OtelExporterStandardEnabled)
98
- // C#: (this.InDocker() || UseLocalOtel) && string.IsNullOrEmpty(Environment.GetEnvironmentVariable("BEAM_DISABLE_STANDARD_OTEL"))
99
- // UseLocalOtel = BEAM_LOCAL_OTEL is set
100
- // InDocker = IS_LOCAL is not set (for Node.js, we check IS_LOCAL !== '1' && !== 'true')
101
- const isInDocker = process.env.IS_LOCAL !== '1' && process.env.IS_LOCAL !== 'true';
102
- const useLocalOtel = !!process.env.BEAM_LOCAL_OTEL;
103
- const standardOtelEnabled = (isInDocker || useLocalOtel) && !process.env.BEAM_DISABLE_STANDARD_OTEL;
104
-
105
- // If no explicit endpoint and standard OTLP not enabled, skip OTLP
106
- if (!otlpEndpoint && !standardOtelEnabled) {
107
- return Promise.resolve(null);
108
- }
109
-
110
- try {
111
- // Build resource attributes (similar to C# resourceProvider)
112
- const resourceAttributes: Record<string, string> = {};
113
- if (serviceName) {
114
- resourceAttributes['service.namespace'] = serviceName;
115
- resourceAttributes['service.name'] = serviceName;
116
- }
117
- if (qualifiedServiceName) {
118
- resourceAttributes['service.instance.id'] = qualifiedServiceName;
119
- }
120
- if (env?.cid) {
121
- resourceAttributes['beam.cid'] = String(env.cid);
122
- }
123
- if (env?.pid) {
124
- resourceAttributes['beam.pid'] = String(env.pid);
125
- }
126
- if (env?.routingKey) {
127
- resourceAttributes['beam.routing_key'] = String(env.routingKey);
128
- }
129
-
130
- // Determine endpoint URL
131
- // If explicit endpoint provided, use it directly (skip discovery/startup)
132
- // If standard OTLP enabled but no explicit endpoint, try to discover or start collector
133
- let endpointUrl = otlpEndpoint;
134
-
135
- // CRITICAL: Only discover/start collector if no explicit endpoint was provided
136
- // If an endpoint is provided, it means the collector was already set up elsewhere
137
- // (e.g., by setupCollectorBeforeLogging in the runtime constructor)
138
- if (!endpointUrl && standardOtelEnabled && logger) {
139
- // Try to discover existing collector or start a new one (like C# does)
140
- try {
141
- const discoveredEndpoint = await discoverOrStartCollector(logger, standardOtelEnabled, env);
142
- if (discoveredEndpoint) {
143
- endpointUrl = discoveredEndpoint;
144
- } else {
145
- console.error('[OTLP] Standard OTLP is enabled but could not discover or start collector.');
146
- return Promise.resolve(null);
147
- }
148
- } catch (error) {
149
- console.error('[OTLP] Failed to discover/start collector:', error instanceof Error ? error.message : String(error));
150
- return Promise.resolve(null);
151
- }
152
- }
153
-
154
- // If still no endpoint, skip OTLP
155
- if (!endpointUrl) {
156
- if (standardOtelEnabled) {
157
- console.error('[OTLP] Standard OTLP is enabled but no endpoint available. Set BEAM_OTEL_EXPORTER_OTLP_ENDPOINT to enable OTLP logging.');
158
- }
159
- return Promise.resolve(null);
160
- }
161
-
162
- // Ensure endpoint format is correct based on protocol
163
- // C# behavior:
164
- // - Http protocol: endpoint should be like "http://127.0.0.1:4348" (with /v1/logs appended)
165
- // - Grpc protocol: endpoint should be like "127.0.0.1:4348" (no http:// prefix, no /v1/logs)
166
- // For now, we only support HTTP exporter, so we append /v1/logs
167
- // If Grpc is specified, we'd need a different exporter package
168
- let finalEndpointUrl = endpointUrl;
169
- if (otlpProtocol && otlpProtocol.toLowerCase() === 'grpc') {
170
- // Grpc protocol - don't modify endpoint (no http://, no /v1/logs)
171
- // Note: Grpc exporter not yet implemented, this is for future support
172
- console.warn('[OTLP] Grpc protocol specified but not yet supported, using HTTP endpoint format');
173
- finalEndpointUrl = endpointUrl.includes('/v1/logs')
174
- ? endpointUrl
175
- : `${endpointUrl.replace(/\/$/, '')}/v1/logs`;
176
- } else {
177
- // HTTP protocol - ensure /v1/logs suffix
178
- finalEndpointUrl = endpointUrl.includes('/v1/logs')
179
- ? endpointUrl
180
- : `${endpointUrl.replace(/\/$/, '')}/v1/logs`;
181
- }
182
-
183
- // Create OTLP HTTP exporter
184
- // Beamable uses HttpProtobuf as the standard protocol
185
- // C# behavior:
186
- // 1. If OtelExporterOtlpEndpoint and OtelExporterOtlpProtocol are provided, use them
187
- // 2. Otherwise, always use HttpProtobuf protocol (Beamable's standard)
188
- // Node.js: @opentelemetry/exporter-logs-otlp-http uses HTTP/JSON by default
189
- // We configure contentType to 'application/x-protobuf' to match Beamable's standard
190
- const exporterOptions: {
191
- url: string;
192
- headers?: Record<string, string>;
193
- contentType?: string;
194
- } = {
195
- url: finalEndpointUrl,
196
- };
197
-
198
- // Configure protocol/contentType (matching C# OtlpExportProtocol)
199
- // Beamable uses HttpProtobuf as the default protocol
200
- // C# supports: HttpProtobuf, HttpJson, Grpc
201
- // If explicit protocol provided, use it; otherwise always default to HttpProtobuf
202
- if (otlpProtocol) {
203
- // Parse protocol string (HttpProtobuf, HttpJson, or Grpc)
204
- const protocol = otlpProtocol.toLowerCase();
205
- if (protocol === 'httpprotobuf' || protocol === 'protobuf') {
206
- exporterOptions.contentType = 'application/x-protobuf';
207
- } else if (protocol === 'httpjson' || protocol === 'json') {
208
- exporterOptions.contentType = 'application/json';
209
- } else if (protocol === 'grpc') {
210
- // Note: Grpc protocol would require a different exporter package
211
- // For now, we'll log a warning and fall back to HttpProtobuf
212
- console.warn('[OTLP] Grpc protocol is not yet supported in Node.js runtime, falling back to HttpProtobuf');
213
- exporterOptions.contentType = 'application/x-protobuf';
214
- } else {
215
- // Invalid protocol - default to HttpProtobuf (Beamable's standard)
216
- console.warn(`[OTLP] Unknown protocol "${otlpProtocol}", defaulting to HttpProtobuf`);
217
- exporterOptions.contentType = 'application/x-protobuf';
218
- }
219
- } else {
220
- // No protocol specified - always default to HttpProtobuf (Beamable's standard)
221
- exporterOptions.contentType = 'application/x-protobuf';
222
- }
223
-
224
- // Headers if provided (matching C# OtelExporterOtlpHeaders)
225
- if (process.env.BEAM_OTEL_EXPORTER_OTLP_HEADERS) {
226
- exporterOptions.headers = JSON.parse(process.env.BEAM_OTEL_EXPORTER_OTLP_HEADERS);
227
- }
228
-
229
- const exporter = new OTLPLogExporter(exporterOptions);
230
-
231
- // Create resource with attributes (merge with default resource)
232
- // Note: BEAM_ALLOW_STARTUP_WITHOUT_ATTRIBUTES_RESOURCE is not applicable here
233
- // as we always create a resource with attributes
234
- const baseResource = defaultResource();
235
- const customResource = resourceFromAttributes(resourceAttributes);
236
- const resource = baseResource.merge(customResource);
237
-
238
- // Create log record processor
239
- // Note: C# supports retry via BEAM_DISABLE_RETRY_OTEL and BEAM_OTEL_RETRY_MAX_SIZE
240
- // SimpleLogRecordProcessor doesn't support retry - would need BatchLogRecordProcessor
241
- // For now, we use SimpleLogRecordProcessor (no retry)
242
- // TODO: Consider implementing retry logic if BEAM_DISABLE_RETRY_OTEL is not set
243
- const processor = new SimpleLogRecordProcessor(exporter);
244
-
245
- // Create logger provider with resource and processor
246
- const loggerProvider = new LoggerProvider({
247
- resource: resource,
248
- processors: [processor],
249
- });
250
-
251
- // Set as global logger provider
252
- logs.setGlobalLoggerProvider(loggerProvider);
253
-
254
- // Log successful initialization (to stdout for debugging)
255
- // This helps diagnose if OTLP is working in deployed environments
256
- console.error(`[OTLP] OpenTelemetry logging initialized. Endpoint: ${finalEndpointUrl}, Service: ${serviceName || 'unknown'}`);
257
-
258
- // CRITICAL: Verify collector is actually ready before returning
259
- // If we started a collector, wait for it to be ready (up to 5 seconds)
260
- if (standardOtelEnabled && logger) {
261
- try {
262
- const { isCollectorRunning } = await import('./collector-manager.js');
263
- // Wait up to 5 seconds for collector to be ready
264
- for (let i = 0; i < 50; i++) {
265
- const status = await isCollectorRunning();
266
- if (status.isRunning && status.isReady) {
267
- if (logger) {
268
- logger.info(`[OTLP] Collector confirmed ready at ${status.otlpEndpoint || finalEndpointUrl}`);
269
- }
270
- break;
271
- }
272
- // Wait 100ms between checks
273
- await new Promise(resolve => setTimeout(resolve, 100));
274
- }
275
- } catch (error) {
276
- // If we can't verify, log but continue
277
- if (logger) {
278
- logger.warn(`[OTLP] Could not verify collector readiness: ${error instanceof Error ? error.message : String(error)}`);
279
- }
280
- }
281
- }
282
-
283
- return Promise.resolve(loggerProvider);
284
- } catch (error) {
285
- // If OTLP initialization fails, log error but continue without OTLP
286
- // Don't throw - we still want stdout logging to work
287
- // Log to stderr so it's visible in container logs
288
- console.error('[OTLP] Failed to initialize OTLP logging:', error instanceof Error ? error.message : String(error));
289
- if (error instanceof Error && error.stack) {
290
- console.error('[OTLP] Stack trace:', error.stack);
291
- }
292
- return Promise.resolve(null);
293
- }
294
- }
295
-
296
- /**
297
- * Creates a transform stream that converts Pino JSON logs to Beamable's expected format.
298
- * Beamable expects logs with __t (timestamp), __l (level), and __m (message) fields.
299
- * Also includes OpenTelemetry-compatible fields for ClickHouse compatibility.
300
- * Pino writes JSON strings (one per line) to the stream.
301
- *
302
- * Also sends logs via OTLP if OTLP logger provider is configured.
303
- */
304
- function createBeamableLogFormatter(
305
- serviceName?: string,
306
- qualifiedServiceName?: string,
307
- otlpProviderRef?: { provider: LoggerProvider | null }
308
- ): Transform {
309
- return new Transform({
310
- objectMode: false, // Pino writes strings/Buffers, not objects
311
- transform(chunk: Buffer, _encoding, callback) {
312
- // Ensure we have a Buffer - Pino may write strings or Buffers
313
- const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
314
- try {
315
- const line = buffer.toString('utf8');
316
- // Skip empty lines
317
- if (!line.trim()) {
318
- callback();
319
- return;
320
- }
321
-
322
- // Parse Pino's JSON log line
323
- const pinoLog = JSON.parse(line);
324
-
325
- // Extract timestamp - Pino uses 'time' field (ISO 8601 string or milliseconds)
326
- // Convert to ISO 8601 string for Beamable
327
- let timestamp: string;
328
- if (typeof pinoLog.time === 'string') {
329
- timestamp = pinoLog.time;
330
- } else if (typeof pinoLog.time === 'number') {
331
- timestamp = new Date(pinoLog.time).toISOString();
332
- } else {
333
- timestamp = new Date().toISOString();
334
- }
335
-
336
- // Map Pino level to Beamable level
337
- const level = mapPinoLevelToBeamableLevel(pinoLog.level);
338
-
339
- // Build the message - combine msg with any additional fields
340
- // Pino's 'msg' field contains the log message
341
- const messageParts: string[] = [];
342
- if (pinoLog.msg) {
343
- messageParts.push(pinoLog.msg);
344
- }
345
-
346
- // Include error information if present
347
- if (pinoLog.err) {
348
- const err = pinoLog.err;
349
- const errMsg = err.message || err.msg || 'Error';
350
- const errStack = err.stack ? `\n${err.stack}` : '';
351
- messageParts.push(`${errMsg}${errStack}`);
352
- }
353
-
354
- // Build the Beamable log format (for CloudWatch Logs Insights)
355
- const beamableLog: Record<string, unknown> = {
356
- __t: timestamp,
357
- __l: level,
358
- __m: messageParts.length > 0 ? messageParts.join(' ') : 'No message',
359
- };
360
-
361
- // Include additional context fields that might be useful
362
- // These are included in the message object but not as top-level fields
363
- const contextFields: Record<string, unknown> = {};
364
- if (pinoLog.cid) contextFields.cid = pinoLog.cid;
365
- if (pinoLog.pid) contextFields.pid = pinoLog.pid;
366
- if (pinoLog.routingKey) contextFields.routingKey = pinoLog.routingKey;
367
- if (pinoLog.service) contextFields.service = pinoLog.service;
368
- if (pinoLog.component) contextFields.component = pinoLog.component;
369
-
370
- // Include service name in context for CloudWatch filtering
371
- if (serviceName) {
372
- contextFields.serviceName = serviceName;
373
- }
374
- if (qualifiedServiceName) {
375
- contextFields.qualifiedServiceName = qualifiedServiceName;
376
- }
377
-
378
- // Include any other fields that aren't standard Pino fields
379
- const standardPinoFields = ['level', 'time', 'pid', 'hostname', 'name', 'msg', 'err', 'v', 'cid', 'pid', 'routingKey', 'sdkVersionExecution', 'service', 'component'];
380
- for (const [key, value] of Object.entries(pinoLog)) {
381
- if (!standardPinoFields.includes(key) && value !== undefined && value !== null) {
382
- contextFields[key] = value;
383
- }
384
- }
385
-
386
- // If there are context fields, include them in the log
387
- if (Object.keys(contextFields).length > 0) {
388
- beamableLog.__c = contextFields;
389
- }
390
-
391
- // Add OpenTelemetry-compatible fields for ClickHouse compatibility
392
- // These fields allow an OpenTelemetry collector to parse and forward logs to ClickHouse
393
- // The Portal's realm-level logs page queries ClickHouse's otel_logs table
394
- const otelFields: Record<string, unknown> = {};
395
-
396
- // ResourceAttributes - service identification (for ClickHouse filtering)
397
- const resourceAttributes: Record<string, unknown> = {};
398
- // IMPORTANT: The Portal searches for service:ExampleNodeService (just the service name)
399
- // So we need to set service.namespace to the service name, NOT the qualified name
400
- // The qualified name (micro_ExampleNodeService) is used for CloudWatch log stream filtering
401
- // But ClickHouse uses just the service name for filtering
402
- if (serviceName) {
403
- resourceAttributes['service.namespace'] = serviceName;
404
- resourceAttributes['service.name'] = serviceName;
405
- }
406
- // Also include qualified name for reference
407
- if (qualifiedServiceName) {
408
- resourceAttributes['service.qualifiedName'] = qualifiedServiceName;
409
- }
410
- if (pinoLog.cid) {
411
- resourceAttributes['beam.cid'] = String(pinoLog.cid);
412
- }
413
- if (pinoLog.pid) {
414
- resourceAttributes['beam.pid'] = String(pinoLog.pid);
415
- }
416
- if (pinoLog.routingKey) {
417
- resourceAttributes['beam.routing_key'] = String(pinoLog.routingKey);
418
- }
419
-
420
- // LogAttributes - log-specific attributes
421
- const logAttributes: Record<string, unknown> = {};
422
- if (pinoLog.component) {
423
- logAttributes['component'] = String(pinoLog.component);
424
- }
425
- if (pinoLog.err) {
426
- const err = pinoLog.err;
427
- if (err.message) logAttributes['exception.message'] = String(err.message);
428
- if (err.stack) logAttributes['exception.stacktrace'] = String(err.stack);
429
- if (err.type) logAttributes['exception.type'] = String(err.type);
430
- }
431
-
432
- // Map Beamable log level to OpenTelemetry SeverityText
433
- // Beamable: Debug, Info, Warning, Error, Fatal
434
- // OpenTelemetry: Trace, Debug, Info, Warn, Error, Fatal, Unspecified
435
- const severityTextMap: Record<string, string> = {
436
- 'Debug': 'Debug',
437
- 'Info': 'Information',
438
- 'Warning': 'Warning',
439
- 'Error': 'Error',
440
- 'Fatal': 'Critical',
441
- };
442
- const severityText = severityTextMap[level] || 'Information';
443
-
444
- // CRITICAL: ResourceAttributes and LogAttributes MUST always be present (even if empty)
445
- // ClickHouse schema expects these fields to exist for proper querying
446
- // The Portal queries ResourceAttributes['service.namespace'] - if ResourceAttributes is missing, queries fail
447
-
448
- // Ensure ResourceAttributes always has service.namespace when serviceName is provided
449
- // This is the primary field used by Portal for service filtering
450
- if (serviceName && !resourceAttributes['service.namespace']) {
451
- resourceAttributes['service.namespace'] = serviceName;
452
- resourceAttributes['service.name'] = serviceName;
453
- }
454
-
455
- // Add OpenTelemetry fields to the log
456
- // These are in addition to the Beamable format, so both systems can parse the logs
457
- // CRITICAL: Always include all required OpenTelemetry fields for ClickHouse compatibility
458
- otelFields['Timestamp'] = timestamp; // OpenTelemetry timestamp format (ISO 8601)
459
- otelFields['SeverityText'] = severityText;
460
- otelFields['Body'] = messageParts.length > 0 ? messageParts.join(' ') : 'No message';
461
- // ALWAYS include ResourceAttributes (even if empty) - required for ClickHouse schema
462
- otelFields['ResourceAttributes'] = resourceAttributes;
463
- // ALWAYS include LogAttributes (even if empty) - required for ClickHouse schema
464
- otelFields['LogAttributes'] = logAttributes;
465
-
466
- // IMPORTANT: For CloudWatch Logs Insights, we need to ensure @message contains valid JSON
467
- // The Portal expects @message to be a JSON string that can be parsed to extract __t, __l, __m
468
- // We output the Beamable format as the primary format, and include OpenTelemetry fields
469
- // CloudWatch will store this as @message, and an OpenTelemetry collector will parse it
470
- // and forward to ClickHouse's otel_logs table
471
-
472
- // Merge OpenTelemetry fields into the log (for ClickHouse compatibility)
473
- // An OpenTelemetry collector can parse these fields and forward to ClickHouse
474
- // Note: These extra fields won't break CloudWatch - it will just store them in @message
475
- Object.assign(beamableLog, otelFields);
476
-
477
- // Send log via OTLP if configured (similar to C# MicroserviceOtelLogRecordExporter)
478
- // Check if provider is available (may be null if still initializing)
479
- if (otlpProviderRef?.provider) {
480
- try {
481
- const otlpLogger = otlpProviderRef.provider.getLogger(
482
- serviceName || 'beamable-node-runtime',
483
- undefined, // version
484
- {
485
- schemaUrl: undefined, // optional schema URL
486
- }
487
- );
488
-
489
- // Map Beamable level to OpenTelemetry SeverityNumber
490
- const severityNumberMap: Record<string, number> = {
491
- 'Debug': 5, // SEVERITY_NUMBER_DEBUG
492
- 'Info': 9, // SEVERITY_NUMBER_INFO
493
- 'Warning': 13, // SEVERITY_NUMBER_WARN
494
- 'Error': 17, // SEVERITY_NUMBER_ERROR
495
- 'Fatal': 21, // SEVERITY_NUMBER_FATAL
496
- };
497
-
498
- // Create log record for OTLP
499
- otlpLogger.emit({
500
- severityNumber: severityNumberMap[level] || 9,
501
- severityText: severityText,
502
- body: messageParts.length > 0 ? messageParts.join(' ') : 'No message',
503
- attributes: {
504
- ...logAttributes,
505
- // Include additional context
506
- ...(pinoLog.component ? { component: String(pinoLog.component) } : {}),
507
- },
508
- timestamp: new Date(timestamp).getTime() * 1_000_000, // nanoseconds
509
- observedTimestamp: Date.now() * 1_000_000, // nanoseconds
510
- });
511
- } catch (otlpError) {
512
- // If OTLP send fails, continue with stdout logging
513
- // Don't block the log output
514
- // Note: C# supports retry via BEAM_DISABLE_RETRY_OTEL and BEAM_OTEL_RETRY_MAX_SIZE
515
- // SimpleLogRecordProcessor doesn't support retry - would need BatchLogRecordProcessor
516
- // For now, we silently continue (retry would be handled at processor level if implemented)
517
- }
518
- }
519
-
520
- // Output as a single-line JSON string (required for CloudWatch)
521
- // CloudWatch Logs Insights will store this entire JSON string in the @message field
522
- const output = JSON.stringify(beamableLog) + '\n';
523
- callback(null, Buffer.from(output, 'utf8'));
524
- } catch (error) {
525
- // If parsing fails, output a fallback log entry
526
- const fallbackLog = {
527
- __t: new Date().toISOString(),
528
- __l: 'Error',
529
- __m: `Failed to parse log entry: ${chunk.toString().substring(0, 200)}`,
530
- };
531
- callback(null, Buffer.from(JSON.stringify(fallbackLog) + '\n', 'utf8'));
532
- }
533
- },
534
- });
535
- }
536
-
537
-
538
- export function createLogger(env: EnvironmentConfig, options: LoggerFactoryOptions = {}): Logger {
539
- const configuredDestination = options.destinationPath ?? process.env.LOG_PATH;
540
- const usePrettyLogs = shouldUsePrettyLogs();
541
-
542
- // Initialize OTLP synchronously BEFORE creating the logger
543
- // If otlpEndpoint is provided (collector already set up), create provider directly
544
- // Otherwise, try to discover/start collector (with timeout)
545
- // Check if standard OTLP is enabled (needed for the else branch)
546
- const isInDocker = process.env.IS_LOCAL !== '1' && process.env.IS_LOCAL !== 'true';
547
- const useLocalOtel = !!process.env.BEAM_LOCAL_OTEL;
548
- const standardOtelEnabled = (isInDocker || useLocalOtel) && !process.env.BEAM_DISABLE_STANDARD_OTEL;
549
-
550
- // Shared reference for OTLP logger provider (create before async operations)
551
- const otlpProviderRef: { provider: LoggerProvider | null } = { provider: null };
552
-
553
- if (options.otlpEndpoint) {
554
- // Collector is already set up, create OTLP provider directly without discovery/startup
555
- // Set endpoint in env temporarily so initializeOtlpLogging uses it directly
556
- const originalEndpoint = process.env.BEAM_OTEL_EXPORTER_OTLP_ENDPOINT;
557
- process.env.BEAM_OTEL_EXPORTER_OTLP_ENDPOINT = options.otlpEndpoint;
558
-
559
- // Create OTLP provider directly (collector is already running, so this should be fast)
560
- const initLogger = pino({
561
- name: 'beamable-otlp-init',
562
- level: 'info',
563
- }, process.stdout);
564
-
565
- // Create OTLP provider asynchronously (non-blocking)
566
- // Since endpoint is explicitly provided and collector is already ready,
567
- // this should complete quickly, but we don't block for it
568
- initializeOtlpLogging(
569
- options.serviceName,
570
- options.qualifiedServiceName,
571
- env,
572
- initLogger
573
- ).then((provider) => {
574
- if (provider) {
575
- otlpProviderRef.provider = provider;
576
- initLogger.info(`[OTLP] OTLP provider created using existing collector at ${options.otlpEndpoint}`);
577
- initLogger.info(`[OTLP] OpenTelemetry logging initialized. Endpoint: ${options.otlpEndpoint}/v1/logs, Service: ${options.serviceName || 'unknown'}`);
578
- } else {
579
- initLogger.warn('[OTLP] OTLP provider creation returned null, structured logs will not be sent via OTLP');
580
- otlpProviderRef.provider = null;
581
- }
582
- }).catch((error) => {
583
- const errorMsg = error instanceof Error ? error.message : String(error);
584
- initLogger.error(`[OTLP] Failed to create OTLP provider: ${errorMsg}`);
585
- otlpProviderRef.provider = null;
586
- });
587
-
588
- // Don't wait - logger will work immediately with console output,
589
- // and OTLP will be added when the provider is ready (very quickly since collector is already running)
590
-
591
- // Restore original endpoint if it existed
592
- if (originalEndpoint !== undefined) {
593
- process.env.BEAM_OTEL_EXPORTER_OTLP_ENDPOINT = originalEndpoint;
594
- } else {
595
- delete process.env.BEAM_OTEL_EXPORTER_OTLP_ENDPOINT;
596
- }
597
- } else {
598
- // No endpoint provided - collector is starting asynchronously in background via startCollectorAsync()
599
- // Don't block here - logger works immediately via stdout, OTLP will connect when collector is ready
600
- otlpProviderRef.provider = null; // Start without OTLP - will connect when collector is ready
601
-
602
- // Start async discovery in background (non-blocking)
603
- // This allows the service to start immediately while collector is downloading/starting
604
- // CRITICAL: Only DISCOVER the collector, don't try to START it (startCollectorAsync handles startup)
605
- if (standardOtelEnabled) {
606
- // Poll for collector to become ready (won't block service startup)
607
- // startCollectorAsync() is already starting it, we just need to wait and connect when ready
608
- (async () => {
609
- const bgLogger = pino({ name: 'beamable-otlp-bg', level: 'info' }, process.stdout);
610
- const maxAttempts = 30; // Check for up to 30 seconds (30 * 1000ms)
611
- const checkInterval = 1000; // Check every 1 second
612
-
613
- for (let attempt = 0; attempt < maxAttempts; attempt++) {
614
- try {
615
- // Import isCollectorRunning dynamically to avoid circular dependencies
616
- const { isCollectorRunning } = await import('./collector-manager.js');
617
- const collectorStatus = await isCollectorRunning();
618
-
619
- if (collectorStatus.isRunning && collectorStatus.isReady && collectorStatus.otlpEndpoint) {
620
- // Collector is ready - initialize OTLP logging now
621
- const endpoint = collectorStatus.otlpEndpoint.startsWith('http')
622
- ? collectorStatus.otlpEndpoint
623
- : `http://${collectorStatus.otlpEndpoint}`;
624
-
625
- const newProvider = await initializeOtlpLogging(
626
- options.serviceName,
627
- options.qualifiedServiceName,
628
- env,
629
- bgLogger
630
- );
631
-
632
- if (newProvider) {
633
- // Update the provider reference so future logs use OTLP
634
- otlpProviderRef.provider = newProvider;
635
- console.error(`[OTLP] Connected to collector at ${endpoint} (background connection)`);
636
- break; // Success, stop polling
637
- }
638
- }
639
- } catch (error) {
640
- // Silently fail and continue polling
641
- }
642
-
643
- // Wait before next check (unless we're on the last attempt)
644
- if (attempt < maxAttempts - 1) {
645
- await new Promise(resolve => setTimeout(resolve, checkInterval));
646
- }
647
- }
648
- })(); // Fire and forget - don't await
649
- }
650
- }
651
-
652
- const pinoOptions: LoggerOptions = {
653
- name: options.name ?? 'beamable-node-runtime',
654
- level: env.logLevel,
655
- base: {
656
- cid: env.cid,
657
- pid: env.pid,
658
- routingKey: env.routingKey ?? null,
659
- sdkVersionExecution: env.sdkVersionExecution,
660
- // Include service name in base fields for filtering
661
- serviceName: options.serviceName,
662
- qualifiedServiceName: options.qualifiedServiceName,
663
- },
664
- redact: {
665
- paths: ['secret', 'refreshToken'],
666
- censor: '***',
667
- },
668
- // Use timestamp in milliseconds (Pino default) for accurate conversion
669
- timestamp: pino.stdTimeFunctions.isoTime,
670
- };
671
-
672
- // For deployed services, always log to stdout so container orchestrator can collect logs
673
- // For local development, log to stdout unless a specific file path is provided
674
- if (!configuredDestination || configuredDestination === '-' || configuredDestination === 'stdout' || configuredDestination === 'console') {
675
- if (!usePrettyLogs) {
676
- // Deployed/remote: Use Beamable JSON format for log collection
677
- // Include OpenTelemetry fields for ClickHouse compatibility
678
- // Also send logs via OTLP if configured
679
- const beamableFormatter = createBeamableLogFormatter(
680
- options.serviceName,
681
- options.qualifiedServiceName,
682
- otlpProviderRef
683
- );
684
- beamableFormatter.pipe(process.stdout);
685
- return pino(pinoOptions, beamableFormatter);
686
- } else {
687
- // Local development: Use Pino's pretty printing for human-readable logs
688
- // Try to use pino-pretty if available (optional dependency)
689
- // If not available, fall back to default Pino JSON output
690
- try {
691
- // Check if pino-pretty is available
692
- // Use getRequire() which handles both CJS and ESM contexts
693
- const requireFn = getRequire();
694
- const pinoPretty = requireFn('pino-pretty');
695
- // Create a pretty stream with formatting options
696
- const prettyStream = pinoPretty({
697
- colorize: true,
698
- translateTime: 'HH:MM:ss.l',
699
- ignore: 'pid,hostname',
700
- singleLine: false,
701
- });
702
- // Use pino with the pretty stream
703
- return pino(pinoOptions, prettyStream);
704
- } catch {
705
- // pino-pretty not available, use default Pino output (JSON but readable)
706
- // This is expected if pino-pretty isn't installed, so we silently fall back
707
- return pino(pinoOptions, process.stdout);
708
- }
709
- }
710
- }
711
-
712
- // For file logging: Use Beamable format if not local, default Pino format if local
713
- const resolvedDestination = configuredDestination === 'temp' ? ensureWritableTempDirectory() : configuredDestination;
714
- if (!usePrettyLogs) {
715
- const beamableFormatter = createBeamableLogFormatter(
716
- options.serviceName,
717
- options.qualifiedServiceName,
718
- otlpProviderRef
719
- );
720
- const fileStream = destination({ dest: resolvedDestination, mkdir: true, append: true, sync: false });
721
- beamableFormatter.pipe(fileStream as unknown as NodeJS.WritableStream);
722
- return pino(pinoOptions, beamableFormatter);
723
- } else {
724
- const fileStream = destination({ dest: resolvedDestination, mkdir: true, append: true, sync: false });
725
- return pino(pinoOptions, fileStream);
726
- }
727
- }