@logtape/otel 1.3.0-dev.379 → 1.3.0-dev.381

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/mod.ts CHANGED
@@ -7,11 +7,12 @@ import {
7
7
  import { diag, type DiagLogger, DiagLogLevel } from "@opentelemetry/api";
8
8
  import {
9
9
  type AnyValue,
10
+ type Logger as OTLogger,
10
11
  type LoggerProvider as LoggerProviderBase,
11
12
  type LogRecord as OTLogRecord,
13
+ NOOP_LOGGER,
12
14
  SeverityNumber,
13
15
  } from "@opentelemetry/api-logs";
14
- import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http";
15
16
  import type { OTLPExporterNodeConfigBase } from "@opentelemetry/otlp-exporter-base";
16
17
  import {
17
18
  defaultResource,
@@ -19,7 +20,6 @@ import {
19
20
  } from "@opentelemetry/resources";
20
21
  import {
21
22
  LoggerProvider,
22
- type LogRecordProcessor,
23
23
  SimpleLogRecordProcessor,
24
24
  } from "@opentelemetry/sdk-logs";
25
25
  import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
@@ -59,16 +59,109 @@ function getEnvironmentVariable(name: string): string | undefined {
59
59
  return undefined;
60
60
  }
61
61
 
62
+ /**
63
+ * Checks if an OTLP endpoint is configured via environment variables or options.
64
+ * Checks the following environment variables:
65
+ * - `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT` (logs-specific endpoint)
66
+ * - `OTEL_EXPORTER_OTLP_ENDPOINT` (general OTLP endpoint)
67
+ *
68
+ * @param config Optional exporter configuration that may contain a URL.
69
+ * @returns `true` if an endpoint is configured, `false` otherwise.
70
+ */
71
+ function hasOtlpEndpoint(config?: OTLPExporterNodeConfigBase): boolean {
72
+ // Check if URL is provided in config
73
+ if (config?.url) {
74
+ return true;
75
+ }
76
+
77
+ // Check environment variables
78
+ const logsEndpoint = getEnvironmentVariable(
79
+ "OTEL_EXPORTER_OTLP_LOGS_ENDPOINT",
80
+ );
81
+ if (logsEndpoint) {
82
+ return true;
83
+ }
84
+
85
+ const endpoint = getEnvironmentVariable("OTEL_EXPORTER_OTLP_ENDPOINT");
86
+ if (endpoint) {
87
+ return true;
88
+ }
89
+
90
+ return false;
91
+ }
92
+
93
+ /**
94
+ * Detects the OTLP protocol from environment variables.
95
+ * Priority:
96
+ * 1. `OTEL_EXPORTER_OTLP_LOGS_PROTOCOL`
97
+ * 2. `OTEL_EXPORTER_OTLP_PROTOCOL`
98
+ * 3. Default: `"http/json"` (for backward compatibility)
99
+ *
100
+ * @returns The detected OTLP protocol.
101
+ */
102
+ function detectOtlpProtocol(): OtlpProtocol {
103
+ const logsProtocol = getEnvironmentVariable(
104
+ "OTEL_EXPORTER_OTLP_LOGS_PROTOCOL",
105
+ );
106
+ if (
107
+ logsProtocol === "grpc" ||
108
+ logsProtocol === "http/protobuf" ||
109
+ logsProtocol === "http/json"
110
+ ) {
111
+ return logsProtocol;
112
+ }
113
+
114
+ const protocol = getEnvironmentVariable("OTEL_EXPORTER_OTLP_PROTOCOL");
115
+ if (
116
+ protocol === "grpc" ||
117
+ protocol === "http/protobuf" ||
118
+ protocol === "http/json"
119
+ ) {
120
+ return protocol;
121
+ }
122
+
123
+ return "http/json";
124
+ }
125
+
126
+ /**
127
+ * Creates an OTLP log exporter based on the detected protocol.
128
+ * Uses dynamic imports to maintain browser compatibility when gRPC is not used.
129
+ * @param config Optional exporter configuration.
130
+ * @returns A promise that resolves to the appropriate OTLP log exporter.
131
+ */
132
+ async function createOtlpExporter(
133
+ config?: OTLPExporterNodeConfigBase,
134
+ // deno-lint-ignore no-explicit-any
135
+ ): Promise<any> {
136
+ const protocol = detectOtlpProtocol();
137
+
138
+ switch (protocol) {
139
+ case "grpc": {
140
+ const { OTLPLogExporter } = await import(
141
+ "@opentelemetry/exporter-logs-otlp-grpc"
142
+ );
143
+ return new OTLPLogExporter(config);
144
+ }
145
+ case "http/protobuf": {
146
+ const { OTLPLogExporter } = await import(
147
+ "@opentelemetry/exporter-logs-otlp-proto"
148
+ );
149
+ return new OTLPLogExporter(config);
150
+ }
151
+ case "http/json":
152
+ default: {
153
+ const { OTLPLogExporter } = await import(
154
+ "@opentelemetry/exporter-logs-otlp-http"
155
+ );
156
+ return new OTLPLogExporter(config);
157
+ }
158
+ }
159
+ }
160
+
62
161
  /**
63
162
  * The OpenTelemetry logger provider.
64
163
  */
65
164
  type ILoggerProvider = LoggerProviderBase & {
66
- /**
67
- * Adds a new {@link LogRecordProcessor} to this logger.
68
- * @param processor the new LogRecordProcessor to be added.
69
- */
70
- addLogRecordProcessor(processor: LogRecordProcessor): void;
71
-
72
165
  /**
73
166
  * Flush all buffered data and shut down the LoggerProvider and all registered
74
167
  * LogRecordProcessor.
@@ -95,14 +188,15 @@ type Message = (string | null | undefined)[];
95
188
  export type BodyFormatter = (message: Message) => AnyValue;
96
189
 
97
190
  /**
98
- * Options for creating an OpenTelemetry sink.
191
+ * The OTLP protocol to use for exporting logs.
192
+ * @since 0.9.0
99
193
  */
100
- export interface OpenTelemetrySinkOptions {
101
- /**
102
- * The OpenTelemetry logger provider to use.
103
- */
104
- loggerProvider?: ILoggerProvider;
194
+ export type OtlpProtocol = "grpc" | "http/protobuf" | "http/json";
105
195
 
196
+ /**
197
+ * Base options shared by all OpenTelemetry sink configurations.
198
+ */
199
+ interface OpenTelemetrySinkOptionsBase {
106
200
  /**
107
201
  * The way to render the message in the log record. If `"string"`,
108
202
  * the message is rendered as a single string with the values are
@@ -128,85 +222,278 @@ export interface OpenTelemetrySinkOptions {
128
222
  * Turned off by default.
129
223
  */
130
224
  diagnostics?: boolean;
225
+ }
226
+
227
+ /**
228
+ * Options for creating an OpenTelemetry sink with a custom logger provider.
229
+ * When using this configuration, you are responsible for setting up the
230
+ * logger provider with appropriate exporters and processors.
231
+ *
232
+ * This is the recommended approach for production use as it gives you
233
+ * full control over the OpenTelemetry configuration.
234
+ * @since 0.9.0
235
+ */
236
+ export interface OpenTelemetrySinkProviderOptions
237
+ extends OpenTelemetrySinkOptionsBase {
238
+ /**
239
+ * The OpenTelemetry logger provider to use.
240
+ */
241
+ loggerProvider: ILoggerProvider;
242
+ }
243
+
244
+ /**
245
+ * Options for creating an OpenTelemetry sink with automatic exporter creation.
246
+ * The protocol is determined by environment variables:
247
+ * - `OTEL_EXPORTER_OTLP_LOGS_PROTOCOL` (highest priority)
248
+ * - `OTEL_EXPORTER_OTLP_PROTOCOL` (fallback)
249
+ * - Default: `"http/json"`
250
+ *
251
+ * For production use, consider providing your own {@link ILoggerProvider}
252
+ * via {@link OpenTelemetrySinkProviderOptions} for more control.
253
+ * @since 0.9.0
254
+ */
255
+ export interface OpenTelemetrySinkExporterOptions
256
+ extends OpenTelemetrySinkOptionsBase {
257
+ /**
258
+ * The OpenTelemetry logger provider to use.
259
+ * Must be undefined or omitted when using exporter options.
260
+ */
261
+ loggerProvider?: undefined;
131
262
 
132
263
  /**
133
264
  * The OpenTelemetry OTLP exporter configuration to use.
134
- * Ignored if `loggerProvider` is provided.
135
265
  */
136
266
  otlpExporterConfig?: OTLPExporterNodeConfigBase;
137
267
 
138
268
  /**
139
269
  * The service name to use. If not provided, the service name is
140
270
  * taken from the `OTEL_SERVICE_NAME` environment variable.
141
- * Ignored if `loggerProvider` is provided.
142
271
  */
143
272
  serviceName?: string;
144
273
  }
145
274
 
275
+ /**
276
+ * Options for creating an OpenTelemetry sink.
277
+ *
278
+ * This is a union type that accepts either:
279
+ * - {@link OpenTelemetrySinkProviderOptions}: Provide your own `loggerProvider`
280
+ * (recommended for production)
281
+ * - {@link OpenTelemetrySinkExporterOptions}: Let the sink create an exporter
282
+ * automatically based on environment variables
283
+ *
284
+ * When no `loggerProvider` is provided, the protocol is determined by:
285
+ * 1. `OTEL_EXPORTER_OTLP_LOGS_PROTOCOL` environment variable
286
+ * 2. `OTEL_EXPORTER_OTLP_PROTOCOL` environment variable
287
+ * 3. Default: `"http/json"`
288
+ *
289
+ * @example Using a custom logger provider (recommended)
290
+ * ```typescript
291
+ * import { LoggerProvider, SimpleLogRecordProcessor } from "@opentelemetry/sdk-logs";
292
+ * import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-grpc";
293
+ *
294
+ * const provider = new LoggerProvider();
295
+ * provider.addLogRecordProcessor(new SimpleLogRecordProcessor(new OTLPLogExporter()));
296
+ *
297
+ * const sink = getOpenTelemetrySink({ loggerProvider: provider });
298
+ * ```
299
+ *
300
+ * @example Using automatic exporter creation
301
+ * ```typescript
302
+ * // Protocol determined by OTEL_EXPORTER_OTLP_PROTOCOL env var
303
+ * const sink = getOpenTelemetrySink({
304
+ * serviceName: "my-service",
305
+ * });
306
+ * ```
307
+ */
308
+ export type OpenTelemetrySinkOptions =
309
+ | OpenTelemetrySinkProviderOptions
310
+ | OpenTelemetrySinkExporterOptions;
311
+
312
+ /**
313
+ * A no-op logger provider that returns NOOP_LOGGER for all requests.
314
+ * Used when no OTLP endpoint is configured to avoid repeated connection errors.
315
+ */
316
+ const noopLoggerProvider: ILoggerProvider = {
317
+ getLogger: () => NOOP_LOGGER,
318
+ };
319
+
320
+ /**
321
+ * Initializes the logger provider asynchronously.
322
+ * This is used when the user doesn't provide a custom logger provider.
323
+ *
324
+ * If no OTLP endpoint is configured (via options or environment variables),
325
+ * returns a noop logger provider to avoid repeated connection errors.
326
+ *
327
+ * @param options The exporter options.
328
+ * @returns A promise that resolves to the initialized logger provider.
329
+ */
330
+ async function initializeLoggerProvider(
331
+ options: OpenTelemetrySinkExporterOptions,
332
+ ): Promise<ILoggerProvider> {
333
+ // If no endpoint is configured, use noop logger provider to avoid
334
+ // repeated transport errors
335
+ if (!hasOtlpEndpoint(options.otlpExporterConfig)) {
336
+ return noopLoggerProvider;
337
+ }
338
+
339
+ const resource = defaultResource().merge(
340
+ resourceFromAttributes({
341
+ [ATTR_SERVICE_NAME]: options.serviceName ??
342
+ getEnvironmentVariable("OTEL_SERVICE_NAME"),
343
+ }),
344
+ );
345
+ const otlpExporter = await createOtlpExporter(options.otlpExporterConfig);
346
+ const loggerProvider = new LoggerProvider({
347
+ resource,
348
+ processors: [
349
+ // @ts-ignore: it works anyway...
350
+ new SimpleLogRecordProcessor(otlpExporter),
351
+ ],
352
+ });
353
+ return loggerProvider;
354
+ }
355
+
356
+ /**
357
+ * Emits a log record to the OpenTelemetry logger.
358
+ * @param logger The OpenTelemetry logger.
359
+ * @param record The LogTape log record.
360
+ * @param options The sink options.
361
+ */
362
+ function emitLogRecord(
363
+ logger: OTLogger,
364
+ record: LogRecord,
365
+ options: OpenTelemetrySinkOptions,
366
+ ): void {
367
+ const objectRenderer = options.objectRenderer ?? "inspect";
368
+ const { category, level, message, timestamp, properties } = record;
369
+ const severityNumber = mapLevelToSeverityNumber(level);
370
+ const attributes = convertToAttributes(properties, objectRenderer);
371
+ attributes["category"] = [...category];
372
+ logger.emit(
373
+ {
374
+ severityNumber,
375
+ severityText: level,
376
+ body: typeof options.messageType === "function"
377
+ ? convertMessageToCustomBodyFormat(
378
+ message,
379
+ objectRenderer,
380
+ options.messageType,
381
+ )
382
+ : options.messageType === "array"
383
+ ? convertMessageToArray(message, objectRenderer)
384
+ : convertMessageToString(message, objectRenderer),
385
+ attributes,
386
+ timestamp: new Date(timestamp),
387
+ } satisfies OTLogRecord,
388
+ );
389
+ }
390
+
146
391
  /**
147
392
  * Creates a sink that forwards log records to OpenTelemetry.
393
+ *
394
+ * When a custom `loggerProvider` is provided, it is used directly.
395
+ * Otherwise, the sink will lazily initialize a logger provider on the first
396
+ * log record, using the protocol determined by environment variables.
397
+ *
148
398
  * @param options Options for creating the sink.
149
399
  * @returns The sink.
150
400
  */
151
401
  export function getOpenTelemetrySink(
152
402
  options: OpenTelemetrySinkOptions = {},
153
- ): Sink {
403
+ ): Sink & AsyncDisposable {
154
404
  if (options.diagnostics) {
155
405
  diag.setLogger(new DiagLoggerAdaptor(), DiagLogLevel.DEBUG);
156
406
  }
157
407
 
158
- let loggerProvider: ILoggerProvider;
159
- if (options.loggerProvider == null) {
160
- const resource = defaultResource().merge(
161
- resourceFromAttributes({
162
- [ATTR_SERVICE_NAME]: options.serviceName ??
163
- getEnvironmentVariable("OTEL_SERVICE_NAME"),
164
- }),
165
- );
166
- loggerProvider = new LoggerProvider({ resource });
167
- const otlpExporter = new OTLPLogExporter(options.otlpExporterConfig);
168
- loggerProvider.addLogRecordProcessor(
169
- // @ts-ignore: it works anyway...
170
- new SimpleLogRecordProcessor(otlpExporter),
171
- );
172
- } else {
173
- loggerProvider = options.loggerProvider;
174
- }
175
- const objectRenderer = options.objectRenderer ?? "inspect";
176
- const logger = loggerProvider.getLogger(metadata.name, metadata.version);
177
- const sink = (record: LogRecord) => {
178
- const { category, level, message, timestamp, properties } = record;
179
- if (
180
- category[0] === "logtape" && category[1] === "meta" &&
181
- category[2] === "otel"
182
- ) {
183
- return;
184
- }
185
- const severityNumber = mapLevelToSeverityNumber(level);
186
- const attributes = convertToAttributes(properties, objectRenderer);
187
- attributes["category"] = [...category];
188
- logger.emit(
408
+ // If loggerProvider is provided, use the synchronous path
409
+ if (options.loggerProvider != null) {
410
+ const loggerProvider = options.loggerProvider;
411
+ const logger = loggerProvider.getLogger(metadata.name, metadata.version);
412
+ const shutdown = loggerProvider.shutdown?.bind(loggerProvider);
413
+ const sink: Sink & AsyncDisposable = Object.assign(
414
+ (record: LogRecord) => {
415
+ const { category } = record;
416
+ if (
417
+ category[0] === "logtape" && category[1] === "meta" &&
418
+ category[2] === "otel"
419
+ ) {
420
+ return;
421
+ }
422
+ emitLogRecord(logger, record, options);
423
+ },
189
424
  {
190
- severityNumber,
191
- severityText: level,
192
- body: typeof options.messageType === "function"
193
- ? convertMessageToCustomBodyFormat(
194
- message,
195
- objectRenderer,
196
- options.messageType,
197
- )
198
- : options.messageType === "array"
199
- ? convertMessageToArray(message, objectRenderer)
200
- : convertMessageToString(message, objectRenderer),
201
- attributes,
202
- timestamp: new Date(timestamp),
203
- } satisfies OTLogRecord,
425
+ async [Symbol.asyncDispose](): Promise<void> {
426
+ if (shutdown != null) await shutdown();
427
+ },
428
+ },
204
429
  );
205
- };
206
- if (loggerProvider.shutdown != null) {
207
- const shutdown = loggerProvider.shutdown.bind(loggerProvider);
208
- sink[Symbol.asyncDispose] = shutdown;
430
+ return sink;
209
431
  }
432
+
433
+ // Lazy initialization for automatic exporter creation
434
+ let loggerProvider: ILoggerProvider | null = null;
435
+ let logger: OTLogger | null = null;
436
+ let initPromise: Promise<void> | null = null;
437
+ let initError: Error | null = null;
438
+
439
+ const sink: Sink & AsyncDisposable = Object.assign(
440
+ (record: LogRecord) => {
441
+ const { category } = record;
442
+ if (
443
+ category[0] === "logtape" && category[1] === "meta" &&
444
+ category[2] === "otel"
445
+ ) {
446
+ return;
447
+ }
448
+
449
+ // If already initialized, emit the log
450
+ if (logger != null) {
451
+ emitLogRecord(logger, record, options);
452
+ return;
453
+ }
454
+
455
+ // If initialization failed, skip silently
456
+ if (initError != null) {
457
+ return;
458
+ }
459
+
460
+ // Start initialization if not already started
461
+ if (initPromise == null) {
462
+ initPromise = initializeLoggerProvider(options)
463
+ .then((provider) => {
464
+ loggerProvider = provider;
465
+ logger = provider.getLogger(metadata.name, metadata.version);
466
+ // Emit the current record that triggered initialization
467
+ emitLogRecord(logger, record, options);
468
+ })
469
+ .catch((error) => {
470
+ initError = error;
471
+ // Log initialization error to console as a fallback
472
+ // deno-lint-ignore no-console
473
+ console.error("Failed to initialize OpenTelemetry logger:", error);
474
+ });
475
+ }
476
+ // Records during initialization are dropped
477
+ // (the triggering record is emitted in the then() callback above)
478
+ },
479
+ {
480
+ async [Symbol.asyncDispose](): Promise<void> {
481
+ // Wait for initialization to complete if in progress
482
+ if (initPromise != null) {
483
+ try {
484
+ await initPromise;
485
+ } catch {
486
+ // Initialization failed, nothing to shut down
487
+ return;
488
+ }
489
+ }
490
+ if (loggerProvider?.shutdown != null) {
491
+ await loggerProvider.shutdown();
492
+ }
493
+ },
494
+ },
495
+ );
496
+
210
497
  return sink;
211
498
  }
212
499