Guides

OpenTelemetry

OTLP/HTTP without the SDK, or a bridge to the SDK you already run.

Two ways in, both in @unhingged/logit/otel, both with the mapping Serilog.Sinks.OpenTelemetry uses — every property an attribute of the same name, the template in message_template.text, the error in exception.*.

OTLP/HTTP, no SDK#

import { configure, consoleSink } from '@unhingged/logit';
import { otlpHttpSink } from '@unhingged/logit/otel';

configure({
  sinks: [
    consoleSink(),
    otlpHttpSink({
      url: 'http://localhost:4318/v1/logs',
      headers: { authorization: `Bearer ${token}` },
      resource: { 'service.name': 'shop', 'service.version': '1.4.0', 'deployment.environment': 'prod' },
    }),
  ],
});

Batches go as OTLP JSON (ExportLogsServiceRequest) over fetch, with the HTTP sink's batching, retries and bounds. The standard variables are honoured, so an empty otlpHttpSink() works in a configured environment:

VariableUsed for
OTEL_EXPORTER_OTLP_LOGS_ENDPOINTthe URL, as is
OTEL_EXPORTER_OTLP_ENDPOINTthe URL, with /v1/logs appended
OTEL_EXPORTER_OTLP_LOGS_HEADERS, OTEL_EXPORTER_OTLP_HEADERSkey=value,key2=value2 headers
OTEL_SERVICE_NAMEservice.name in the resource

Default URL: http://localhost:4318/v1/logs. Collectors, Grafana Cloud, Honeycomb, Dash0, Datadog's OTLP intake and the rest all take this.

Mapping#

logitOTLP log record
timestamptimeUnixNano, observedTimeUnixNano
levelseverityNumber 1 / 5 / 9 / 13 / 17 / 21, severityText TRACE / DEBUG / INFO / WARN / ERROR / FATAL
rendered messagebody.stringValue
messageTemplateattribute message_template.text (templateAttribute to rename, false to drop)
each propertyan attribute of the same name; nested objects as kvlistValue, arrays as arrayValue, integers as intValue, dates as ISO strings
errorexception.type, exception.message, exception.stacktrace
traceId, spanIdtraceId, spanId
resource optionresource attributes
scope optionthe instrumentation scope; default @unhingged/logit

encodeOtlpLogs(events, options) and toAnyValue(value) are exported if you need the payload for a transport of your own.

Bridge to the SDK#

When the application already runs the OpenTelemetry SDK — for traces — hand log events to its LoggerProvider and let its exporters, resource and context propagation do the rest:

import { logs } from '@opentelemetry/api-logs';
import { otelBridgeSink } from '@unhingged/logit/otel';

configure({ sinks: [consoleSink(), otelBridgeSink(logs.getLoggerProvider(), { scope: { name: 'shop', version: '1.4.0' } })] });

The provider is typed structurally — getLogger(name, version).emit(record) — so the package depends on neither @opentelemetry/api-logs nor the SDK. OpenTelemetry attributes are flat, so nested properties are flattened with dots (Address.city); flatten: false passes them through for an SDK that accepts nested values.

Trace context on every event#

Until the automatic enricher on the roadmap lands, hand the active span to withTraceContext:

import { trace } from '@opentelemetry/api';
import { withTraceContext } from '@unhingged/logit';

configure({
  enrichers: [withTraceContext(() => trace.getActiveSpan()?.spanContext())],
});

The getter returns { traceId, spanId } (a SpanContext fits). The ids land on event.traceId / event.spanId — @tr / @sp in CLEF, the trace fields in OTLP, {TraceId} in an output template. Putting traceId / spanId in a LogContext does the same thing.