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:
| Variable | Used for |
|---|---|
OTEL_EXPORTER_OTLP_LOGS_ENDPOINT | the URL, as is |
OTEL_EXPORTER_OTLP_ENDPOINT | the URL, with /v1/logs appended |
OTEL_EXPORTER_OTLP_LOGS_HEADERS, OTEL_EXPORTER_OTLP_HEADERS | key=value,key2=value2 headers |
OTEL_SERVICE_NAME | service.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#
| logit | OTLP log record |
|---|---|
timestamp | timeUnixNano, observedTimeUnixNano |
level | severityNumber 1 / 5 / 9 / 13 / 17 / 21, severityText TRACE / DEBUG / INFO / WARN / ERROR / FATAL |
| rendered message | body.stringValue |
messageTemplate | attribute message_template.text (templateAttribute to rename, false to drop) |
| each property | an attribute of the same name; nested objects as kvlistValue, arrays as arrayValue, integers as intValue, dates as ISO strings |
error | exception.type, exception.message, exception.stacktrace |
traceId, spanId | traceId, spanId |
resource option | resource attributes |
scope option | the 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.