Dash0 Raises $110M Series B at $1B Valuation

Last updated: May 19, 2026

Bridging Python's Logging Module to OpenTelemetry (Complete Guide)

Python's logging module gives you structured output with hierarchical loggers, handlers, and formatters, but the records it produces are disconnected from the rest of your observability stack by default.

Those records need to reach an observability pipeline where they can be transformed into OpenTelemetry-compliant log records, correlated with traces, enriched with resource attributes, and shipped to whatever observability backend your team uses.

The OpenTelemetry Python SDK includes a LoggingHandler that bridges this gap. It plugs into Python's standard logging infrastructure as a regular handler, converts each LogRecord into an OTel log record, and exports it via the OpenTelemetry Protocol (OTLP) without requiring you to change your logging calls.

This guide walks through the full setup: from wiring the OTel handler, to inspecting the resulting OTel log records in a Collector, correlating them with traces, and shipping everything to a production backend.

Prerequisites

You'll need the following to follow along:

  • Python 3.14 or later.
  • A Python web application (we'll use FastAPI in the examples, but any framework works).
  • An OpenTelemetry Collector instance to see the pipeline in action.
  • Familiarity with Python's standard logging module (see our Python logging guide if you need a refresher).

Setting up the demo: a FastAPI app with JSON logging

To demonstrate the concepts we'll be exploring in this tutorial, let's set up a small FastAPI application with JSON logging. If you've read our Python logging guide, this is a condensed version of the patterns covered there.

Create a project directory and a virtual environment:

bash
123
mkdir python-otel-logging && cd python-otel-logging
python -m venv .venv
source .venv/bin/activate

Then install the necessary dependencies:

bash
1
pip install fastapi uvicorn python-json-logger pyyaml

Create a logging.yaml that configures a JSON formatter via python-json-logger and routes both your application logs and uvicorn's logs through the same handler:

yaml
123456789101112131415161718192021222324
# logging.yaml
version: 1
disable_existing_loggers: false
formatters:
json:
class: pythonjsonlogger.json.JsonFormatter
format: >-
%(asctime)s %(name)s %(levelname)s %(message)s
handlers:
console:
class: logging.StreamHandler
formatter: json
stream: ext://sys.stdout
loggers:
uvicorn:
handlers: [console]
level: ERROR
propagate: false
root:
level: INFO
handlers: [console]

The explicit uvicorn entry ensures that uvicorn's own log output goes through the JSON formatter instead of its default text format. It is also set to ERROR so that console output stays focused on the log calls you've explicitly added rather than uvicorn's per-request access lines.

Now create the application in main.py. The logging config loads at module level so it's applied before uvicorn configures its own loggers:

python
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657
# main.py
import logging
import logging.config
from contextlib import asynccontextmanager
from pathlib import Path
import yaml
from fastapi import FastAPI
from fastapi.responses import JSONResponse
logger = logging.getLogger(__name__)
def load_logging_config():
config_path = Path(__file__).parent / "logging.yaml"
with open(config_path) as f:
config = yaml.safe_load(f)
logging.config.dictConfig(config)
@asynccontextmanager
async def lifespan(app: FastAPI):
load_logging_config()
logger.info("Application started")
yield
logger.info("Application shutting down")
app = FastAPI(lifespan=lifespan)
@app.get("/orders/{order_id}")
async def get_order(order_id: int):
logger.info(
"Processing order",
extra={
"order_id": order_id,
"total": 99.99,
"currency": "USD",
},
)
if order_id > 1000:
try:
raise ValueError(f"Order ID {order_id} exceeds maximum")
except ValueError:
logger.exception(
"Invalid order ID",
extra={"order_id": order_id},
)
return JSONResponse(
{"error": "invalid order ID"},
status_code=400,
)
return {"order_id": order_id, "status": "completed"}

Pass --log-config to uvicorn so it uses the same YAML instead of applying its own defaults on top:

bash
1
uvicorn main:app --port 8000 --log-config logging.yaml

Then make a request to the /orders endpoint:

bash
1
curl http://localhost:8000/orders/123

Everything comes out as newline-delimited JSON:

json
12
{"asctime": "2026-05-19 12:12:00,885", "name": "main", "levelname": "INFO", "message": "Application started"}
{"asctime": "2026-05-19 12:12:03,035", "name": "main", "levelname": "INFO", "message": "Processing order", "order_id": 123, "total": 99.99, "currency": "USD"}

This is your baseline for producing useful logs in a production environment, but the problem is that these records are isolated. They don't carry trace context, they don't follow the OpenTelemetry data model, and correlating them with traces from other services requires parsing and post-processing on the backend side.

In the upcoming sections, we'll add the OTel LoggingHandler to this setup so that the same logging calls also produce records that follow the OpenTelemetry logs data model instead of just structured JSON.

How the OpenTelemetry LoggingHandler works

In Python's logging module, handlers control where log records end up. A StreamHandler writes to a stream (usually stdout), a SocketHandler sends them to a network socket, and so on. Formatting the log is normally left to a logging.Formatter instance that's attached to the handler as seen in the previous section.

However, the OpenTelemetry LoggingHandler does both. It translates each logging.LogRecord into an OpenTelemetry LogRecord and routes it through the OTel Logs SDK.

Timestamps map to OTel's nanosecond-precision fields, Python log levels become OTel severity numbers, the message becomes the record body, and anything you passed through extra becomes typed OTel attributes. If there's an active trace span on the current thread, the handler grabs its trace and span IDs and attaches those too.

From there, the SDK's processing pipeline takes over: batching records, applying any configured processors, and handing the batch to an exporter that serializes everything as OTLP and ships it to your Collector sidecar or observability backend.

The net effect is that your logging calls stay the same, but what comes out the other end is a first-class OTel signal, with trace context, resource metadata, and typed attributes that match the same data model your traces and metrics use.

Setting up the OpenTelemetry Logs SDK

In this section, you'll add the OpenTelemetry SDK as a second handler alongside the JSON console output you already have. Each logging call will then produce both a JSON line on stdout and an OTel log record routed through the SDK's export pipeline.

Start by installing Python's OpenTelemetry SDK:

bash
1
pip install opentelemetry-sdk

This pulls in a few packages:

text
1234
+ opentelemetry-api==1.42.0
+ opentelemetry-sdk==1.42.0
+ opentelemetry-semantic-conventions==0.63b0
+ typing-extensions==4.15.0
  • The opentelemetry-api defines the interfaces (the LoggerProvider, Logger, and LogRecord contracts),
  • opentelemetry-sdk provides the concrete implementations, including the LoggingHandler, processors, and exporters we'll use throughout this guide,
  • And opentelemetry-semantic-conventions contains the standardized attribute names that keep your telemetry consistent across languages and services.

Create an otel_setup.py that initializes the LoggerProvider and registers it globally. We'll start with the ConsoleLogExporter so you can see the OTel representation of each log record alongside your JSON console output:

python
123456789101112131415161718192021222324
# otel_setup.py
from opentelemetry._logs import set_logger_provider
from opentelemetry.sdk._logs import LoggerProvider
from opentelemetry.sdk._logs.export import (
SimpleLogRecordProcessor,
ConsoleLogExporter,
)
from opentelemetry.sdk.resources import Resource
def setup_otel():
resource = Resource.create(
{
"service.name": "order-service",
}
)
provider = LoggerProvider(resource=resource)
provider.add_log_record_processor(
SimpleLogRecordProcessor(ConsoleLogExporter())
)
set_logger_provider(provider)
return provider

We're using SimpleLogRecordProcessor so that every record flushes immediately. Later on, you'll swap this for BatchLogRecordProcessor with an OTLP exporter to buffer records and reduce network round trips when exporting to a Collector instance.

A quick note on the import paths: opentelemetry.sdk._logs uses a leading underscore because the Logs signal in the Python SDK is still officially experimental. The underlying OTel spec has marked the Logs SDK as stable, but the Python implementation hasn't been promoted yet. In practice, the API has been usable in production for several releases, but be aware that breaking changes in minor versions are technically possible.

Now add the otel handler to logging.yaml alongside your existing JSON console handler:

yaml
123456789101112131415161718192021222324252627
# logging.yaml
version: 1
disable_existing_loggers: false
formatters:
json:
class: pythonjsonlogger.json.JsonFormatter
format: >-
%(asctime)s %(name)s %(levelname)s %(message)s
handlers:
console:
class: logging.StreamHandler
formatter: json
stream: ext://sys.stdout
otel:
class: opentelemetry.sdk._logs.LoggingHandler
loggers:
uvicorn:
handlers: [console]
level: ERROR
propagate: false
root:
level: INFO
handlers: [console, otel]

The otel handler doesn't need an explicit logger_provider argument since it falls back to whatever provider was registered globally through set_logger_provider(), which is why setup_otel() must run before the logging config is loaded.

Update main.py to initialize the OTel provider before loading the logging config:

python
12345678910111213141516171819202122232425262728293031323334
# main.py
import logging
import logging.config
from contextlib import asynccontextmanager
from pathlib import Path
import yaml
from fastapi import FastAPI
from otel_setup import setup_otel
def load_logging_config():
config_path = Path(__file__).parent / "logging.yaml"
with open(config_path) as f:
config = yaml.safe_load(f)
logging.config.dictConfig(config)
provider = setup_otel()
load_logging_config()
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
logger.info("Application started")
yield
logger.info("Application shutting down")
provider.shutdown()
app = FastAPI(lifespan=lifespan)
# [...]

The ordering matters: setup_otel() registers the global provider, then load_logging_config() instantiates the LoggingHandler from the YAML, which discovers that provider automatically.

Seeing the console output

Try it out by restarting the application and sending a request once again:

bash
1
curl http://localhost:8000/orders/123

Every logging call now produces two outputs: a JSON line on stdout from the console handler, and an OTel log record from the otel handler via the ConsoleLogExporter. The JSON line looks the same as before:

json
123456789
{
"asctime": "2026-05-19 12:37:16,952",
"name": "main",
"levelname": "INFO",
"message": "Processing order",
"order_id": 123,
"total": 99.99,
"currency": "USD"
}

The OTel equivalent is a different shape entirely:

json
1234567891011121314151617181920212223242526272829
{
"body": "Processing order",
"severity_number": 9,
"severity_text": "INFO",
"attributes": {
"order_id": 123,
"total": 99.99,
"currency": "USD",
"code.file.path": "/home/ayo/dev/dash0/demo/python-logging-otel/main.py",
"code.function.name": "get_order",
"code.line.number": 38
},
"dropped_attributes": 0,
"timestamp": "2026-05-19T11:37:16.952331Z",
"observed_timestamp": "2026-05-19T11:37:16.952781Z",
"trace_id": "0x00000000000000000000000000000000",
"span_id": "0x0000000000000000",
"trace_flags": 0,
"resource": {
"attributes": {
"telemetry.sdk.language": "python",
"telemetry.sdk.name": "opentelemetry",
"telemetry.sdk.version": "1.42.0",
"service.name": "order-service"
},
"schema_url": ""
},
"event_name": ""
}

The otel handler did more than just copy fields over. It translated the Python LogRecord into a different data model, and that mapping is worth understanding.

How Python logging maps to OTel log records

When the bridge converts Python's LogRecord into an OTel log record, it maps the fields as follows:

  • The asctime field becomes OTel's timestamp, while observed_timestamp records when the SDK processed the record.

  • The log message ("Processing order") becomes the OTel record's body.

  • Python's log levels are mapped to OTel's severity scale using a static offset:

    Python levelValueseverity_numberseverity_text
    DEBUG105DEBUG
    INFO209INFO
    WARNING3013WARN
    ERROR4017ERROR
    CRITICAL5021FATAL

    If you've defined custom log levels (which are just integers in Python), they'll map to the nearest OTel severity by the same offset arithmetic.

  • The order_id, total, and currency fields you passed through extra appear as typed OTel attributes.

  • The code.file.path, code.function.name, and code.line.number attributes are derived from the LogRecord's pathname, funcName, and lineno fields. They've been renamed by the Handler to ensure compliance with semantic conventions for source code context.

  • When you log an exception (try it with curl http://localhost:8000/orders/1230), the handler automatically extracts the exception details and maps them to semantic convention attribute names for exceptions:

    json
    123456789101112
    {
    "attributes": {
    "order_id": 1230,
    "code.file.path": "/home/ayo/dev/dash0/demo/python-logging-otel/main.py",
    "code.function.name": "get_order",
    "code.line.number": 51,
    "exception.type": "ValueError",
    "exception.message": "Order ID 1230 exceeds maximum",
    "exception.stacktrace": "Traceback (most recent call last):\n File \"/home/ayo/dev/dash0/demo/python-logging-otel/main.py\", line 49, in get_order\n
    raise ValueError(f\"Order ID {order_id} exceeds maximum\")\nValueError: Order ID 1230 exceeds maximum\n"
    },
    }
  • The resource block carries metadata about the entity producing the telemetry, not about any individual log event. The service.name entry came from the Resource you configured in setup_otel(), while telemetry.sdk.* attributes were attached automatically by the SDK.

    These sit at the resource level in the OpenTelemetry Protocol, separate from per-event attributes, and are what observability backends use for infrastructure-level grouping and filtering.

  • The trace context fields (trace_id, span_id, and trace_flags) are zeroed out because tracing is not active for the application. We'll fix that in the trace correlation section.

Note that this JSON output is only ConsoleLogExporter's debug representation of an OTel LogRecord. When you switch to the OTLP exporter later, this same data gets serialized into the actual OTLP wire format that will be sent to your Collector or observability backend.

Setting up log-trace correlation

When the LoggingHandler is set up alongside an active tracer, your logs will automatically carry the trace ID and span ID from whatever span is active. Your backend can then connect a failing request to the exact logs emitted during its execution, and vice versa.

In the previous section, the trace and span IDs were all zeros because there was no active span. Let's change that by providing a TracerProvider that creates spans, and instrumentation that starts spans for incoming HTTP requests.

Start by installing the FastAPI instrumentation:

bash
1
pip install opentelemetry-instrumentation-fastapi

Then update otel_setup.py to initialize both a LoggerProvider and a TracerProvider sharing the same Resource:

python
12345678910111213141516171819202122232425262728293031323334
# otel_setup.py
from opentelemetry import trace
from opentelemetry._logs import set_logger_provider
from opentelemetry.sdk._logs import LoggerProvider
from opentelemetry.sdk._logs.export import (
ConsoleLogExporter,
SimpleLogRecordProcessor,
)
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import (
ConsoleSpanExporter,
SimpleSpanProcessor,
)
def setup_otel():
resource = Resource.create(
{
"service.name": "order-service",
}
)
tracer_provider = TracerProvider(resource=resource)
tracer_provider.add_span_processor(SimpleSpanProcessor(ConsoleSpanExporter()))
trace.set_tracer_provider(tracer_provider)
logger_provider = LoggerProvider(resource=resource)
logger_provider.add_log_record_processor(
SimpleLogRecordProcessor(ConsoleLogExporter())
)
set_logger_provider(logger_provider)
return tracer_provider, logger_provider

Both providers share the same Resource so your logs and traces carry identical service metadata. We're still using Simple* processors and Console* exporters so that everything prints to stdout immediately. An upcoming section will cover switching to OTLP with batch processors for production.

In main.py, you need to instrument the FastAPI app and update the shutdown sequence:

python
1234567891011121314151617181920212223242526
# main.py
# [...]
from opentelemetry.instrumentation.fastapi import (
FastAPIInstrumentor,
)
from otel_setup import setup_otel
# [...]
tracer_provider, logger_provider = setup_otel()
load_logging_config()
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
logger.info("Application started")
yield
logger.info("Application shutting down")
tracer_provider.shutdown()
logger_provider.shutdown()
app = FastAPI(lifespan=lifespan)
FastAPIInstrumentor.instrument_app(app)
# [...]

Now when you make a request and check the console output, you'll see populated trace context on each log record that's emitted during an active request:

json
1234567891011121314
{
"body": "Processing order",
"severity_number": 9,
"severity_text": "INFO",
"attributes": {...},
"dropped_attributes": 0,
"timestamp": "2026-05-19T12:34:07.963078Z",
"observed_timestamp": "2026-05-19T12:34:07.963221Z",
"trace_id": "0xb90efa268a5a44e50ac68be00643f2aa",
"span_id": "0xaea888c5a344b0cf",
"trace_flags": 3,
"resource": {...},
"event_name": ""
}

You'll also see the corresponding span and its debug representation in stdout:

json
123456789
{
"name": "GET /orders/{order_id}",
"context": {
"trace_id": "0xb90efa268a5a44e50ac68be00643f2aa",
"span_id": "0xaea888c5a344b0cf",
"trace_state": "[]"
},
[...]
}

The trace ID and span ID now match the span that FastAPI's instrumentation created for the HTTP request. This lets your backend link each log entry to the exact request trace.

Replacing the span event API with logging

OpenTelemetry's tracing API has a span.add_event() method for attaching timestamped annotations to a span:

python
1234567
from opentelemetry import trace
span = trace.get_current_span()
span.add_event(
"cache miss",
attributes={"key": "user:42"},
)

You might have used this to record milestones during a span's lifetime. The problem was that it gave you two separate mechanisms for recording the same kind of data: span events through the Tracing API and log records through the Logs API. Both carried timestamps and attributes, but they ended up in different pipelines.

The OpenTelemetry project has since deprecated the span event API in favor of using log records for this purpose. Since the LoggingHandler already correlates logs with the active span, you already get span-event-like behavior without a separate API.

If you want a stable, queryable identifier for an event type (the role a span event name used to fill), you only need to pass it as an otel.event.name attribute:

python
123456789101112131415
logger.info(
"Processing order",
extra={
"otel.event.name": "order.processing",
"order_id": order_id,
},
)
logger.info(
"Order completed",
extra={
"otel.event.name": "order.completed",
"order_id": order_id,
},
)

Ideally the LoggingHandler would automatically promote otel.event.name to the top-level event_name field, but it doesn't at the time of writing. You can fix this in the Collector with the transform processor:

yaml
1234567
processors:
transform/event_name:
log_statements:
- context: log
statements:
- set(event_name, attributes["otel.event.name"]) where attributes["otel.event.name"] != nil
- delete_key(attributes, "otel.event.name")

This moves the attribute into the correct top-level field before export, so your backend can filter and group by event name directly.

Naming log attributes with semantic conventions

One of the things you get by adopting OpenTelemetry is a shared vocabulary for telemetry attributes across every service in your stack. The semantic conventions define standardized, dot-separated names for common attributes so that a Python service, a Go service, and a .NET service all describe the same concept the same way.

You saw this in action in the OpenTelemetry output for the "Processing order" log record. The code.file.path, code.function.name, and code.line.number attributes that the LoggingHandler attached automatically all follow the convention, while the order_id, total, and currency fields from your extra dict don't, because you named them yourself.

That inconsistency matters once you're querying across services. If your Python logs use order_id, your Go service uses orderID, and your .NET service uses OrderId, you've adopted a shared protocol without gaining the interoperability it's supposed to give you.

The semantic convention registry defines standardized names for common attributes. Before inventing a name, check whether one already exists. Some common ones worth knowing:

Instead ofUse
user_iduser.id
status_codehttp.response.status_code
request_pathurl.path
request_methodhttp.request.method
client_ipclient.address

Domain-specific attributes like order_id or currency don't map to anything in the registry, and that's fine. For these, adopt the same dot-separated lowercase pattern and use a namespace prefix that identifies your domain or organization (e.g. come.acme.order.id, com.acme.order.currency).

Where you can't control attribute names at the source (third-party libraries, framework-generated fields), the Collector's transform processor can rename them in transit using the OpenTelemetry Transform Languages.

The important thing is picking a convention and keeping it consistent across services so that queries and dashboards work the same way regardless of which service emitted the data.

Moving to OTLP and the Collector

The Console* exporters are good for verifying that everything is wired up and for debugging during development, but they won't get your telemetry to a observability backend.

For that, you need to switch to OTLP exporters and send data through an OpenTelemetry Collector.

Start by installing the OTLP HTTP exporter:

bash
1
pip install opentelemetry-exporter-otlp-proto-http

Then update otel_setup.py to use OTLP exporters for both traces and logs. Switch to batch processors at the same time so records are buffered and flushed in bulk rather than exported one at a time:

python
12345678910111213141516171819202122232425262728293031323334
# otel_setup.py
from opentelemetry import trace
from opentelemetry._logs import set_logger_provider
from opentelemetry.exporter.otlp.proto.http._log_exporter import (
OTLPLogExporter,
)
from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
OTLPSpanExporter,
)
from opentelemetry.sdk._logs import LoggerProvider
from opentelemetry.sdk._logs.export import (
BatchLogRecordProcessor,
)
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
def setup_otel():
resource = Resource.create(
{
"service.name": "order-service",
}
)
tracer_provider = TracerProvider(resource=resource)
tracer_provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter()))
trace.set_tracer_provider(tracer_provider)
logger_provider = LoggerProvider(resource=resource)
logger_provider.add_log_record_processor(BatchLogRecordProcessor(OTLPLogExporter()))
set_logger_provider(logger_provider)
return tracer_provider, logger_provider

The OTLP exporters default to http://localhost:4318, which matches the standard Collector HTTP endpoint. If your Collector runs elsewhere, override it with the OTEL_EXPORTER_OTLP_ENDPOINT environment variable.

Next, let's set up a local Collector to receive the data. The debug exporter prints received telemetry to the Collector's stdout, which is the fastest way to verify that your pipeline is working end to end.

Create Collector configuration file in your project root:

yaml
12345678910111213141516171819
# otelcol.yaml
receivers:
otlp:
protocols:
http:
endpoint: 0.0.0.0:4318
exporters:
debug:
verbosity: detailed
service:
pipelines:
traces:
receivers: [otlp]
exporters: [debug]
logs:
receivers: [otlp]
exporters: [debug]

The otlp receiver listens for incoming telemetry on port 4318 over HTTP, which matches the default endpoint that the Python OTLP exporters connect to, while the debug exporter prints everything it receives to the Collector's stdout with detailed verbosity, so you can inspect the full contents of each span and log record.

Both traces and logs share the same receiver but flow through separate pipelines, which means you can add different processors or exporters to each signal independently later on.

Now spin up a Collector instance with Docker Compose:

yaml
12345678910
# docker-compose.yml
services:
otelcol:
image: otel/opentelemetry-collector-contrib:0.152.0
container_name: otelcol
ports:
- 4318:4318
volumes:
- ./otelcol.yaml:/etc/otelcol-contrib/config.yaml
restart: unless-stopped
bash
1
docker compose up -d

Restart the application, send a request, and tail the Collector's output:

bash
1
docker compose logs --no-log-prefix -f otelcol

You should see both the trace span and the correlated log record come through. The log output will look something like this:

text
1234567891011121314151617181920212223242526
2026-05-19T13:06:15.549Z info ResourceLog #0
Resource SchemaURL:
Resource attributes:
-> telemetry.sdk.language: Str(python)
-> telemetry.sdk.name: Str(opentelemetry)
-> telemetry.sdk.version: Str(1.42.0)
-> service.name: Str(order-service)
ScopeLogs #0
ScopeLogs SchemaURL:
InstrumentationScope main
LogRecord #0
ObservedTimestamp: 2026-05-19 13:06:14.921912569 +0000 UTC
Timestamp: 2026-05-19 13:06:14.921751552 +0000 UTC
SeverityText: INFO
SeverityNumber: Info(9)
Body: Str(Processing order)
Attributes:
-> order_id: Int(123)
-> total: Double(99.99)
-> currency: Str(USD)
-> code.file.path: Str(/home/ayo/dev/dash0/demo/python-logging-otel/main.py)
-> code.function.name: Str(get_order)
-> code.line.number: Int(44)
Trace ID: d3e70f4d6599fad9b0667fe8775bb020
Span ID: 41b3b124bffc05ba
Flags: 3

The trace and span IDs now match the span that FastAPI's instrumentation created for the request. If you scroll through the Collector's output, you'll find the corresponding trace span with the same IDs.

Sending Python logs to Dash0

Once your logs are flowing through the Collector and you've confirmed that resource attributes, trace correlation, and semantic conventions all look correct, you're ready to point the pipeline at a production backend.

Dash0 is built natively on the OpenTelemetry data model, so there's no translation layer between what the Collector exports and what Dash0 stores and queries.

Add an OTLP exporter to your Collector config pointed at your Dash0 ingress endpoint as follows:

yaml
1234567891011121314151617
exporters:
debug:
verbosity: detailed
otlphttp/dash0:
endpoint: "https://ingress.eu1.dash0.com"
headers:
Authorization: "Bearer ${DASH0_AUTH_TOKEN}"
service:
pipelines:
traces:
receivers: [otlp]
exporters: [otlphttp/dash0]
logs:
receivers: [otlp]
exporters: [otlphttp/dash0]

This is where everything you've built in this guide comes together. the structured attributes you passed through extra arrive as typed, queryable fields, the resource attributes let you scope an investigation to a specific service, and the trace correlation fields let you jump from a log record directly into the trace that produced it.

Jumping from logs to traces in Dash0

Dash0's Triage takes this further by performing automated comparative analysis across your log (and trace) data, and surfacing which attribute values correlate with the group you're investigating.

Whether you're comparing error logs against healthy ones, or looking at a spike on a subset of endpoints, Triage highlights the distinguishing attributes without you having to guess which dimension to filter by first.

Dash0's Triage feature can help you find the root cause faster

An alternative approach: Collector-side conversion

The setup we've built so far converts log records into OTel model inside your application process. For most services, the additional overhead is negligible, but if you're in a situation where logging throughput is a measured bottleneck, there's another path: writing structured JSON to stdout or a file and letting the Collector handle the conversion to the OTel model

The tradeoff is automatic trace correlation as the LoggingHandler reads the active span's trace and span IDs from the OTel context automatically. If you skip it, you need to inject those IDs into your JSON output yourself. The opentelemetry-instrumentation-logging package handles this:

bash
1
pip install opentelemetry-instrumentation-logging
python
12345
from opentelemetry.instrumentation.logging import (
LoggingInstrumentor,
)
LoggingInstrumentor().instrument(set_logging_format=True)

Once instrumented, every LogRecord gets otelTraceId, otelSpanId, and otelTraceSampled attributes injected into it, and these fields appear automatically in the JSON output:

json
12345678910111213
{
"asctime": "2026-05-19 15:18:19,383",
"name": "main",
"levelname": "INFO",
"message": "Processing order",
"otelSpanID": "e05e410ca6f16e62",
"otelTraceID": "dfa72d6e41d363e48a7b5c05e3938bae",
"otelTraceSampled": true,
"otelServiceName": "",
"order_id": 123,
"total": 99.99,
"currency": "USD"
}

The Collector can inject these entries using the appropriate receiver for your environment, parse these fields and map them to the canonical fields in the OTel log data model during ingestion, giving you the identical result without the in-process LoggingHandler.

The important difference is that the Collector can only work with what's already in the JSON. Any context you want in the final OTel record, whether that's trace IDs, resource metadata, or custom attributes, has to be present in the log line at the time it's written.

The LoggingHandler automatically maps Python severity levels to OTel severity numbers, attaches code.* source location attributes, and excludes redundant LogRecord fields. With the Collector-side approach, you're responsible for including the right fields upfront, and for writing OTTL statements to map them to their semantic convention equivalents during ingestion.

For most teams, the in-process LoggingHandler is the simpler path. The Collector-side approach is worth considering when you have a specific reason to keep OTel SDK dependencies out of your application, or when you're dealing with services that already produce well-structured JSON and you'd rather not (or are unable to) change them.

Final thoughts

The OpenTelemetry LoggingHandler connects Python's familiar logging API to the OTel ecosystem without requiring you to rewrite how your application logs. Once it's wired up, every logging call flows into your observability pipeline as a first-class OTel log record, with trace context, typed attributes, and resource metadata attached.

For more on the Python logging fundamentals that feed into this pipeline, see our Python logging guide. If you're evaluating Python logging libraries, our comparison guide covers the tradeoffs between the standard module, structlog, and Loguru.

For the Collector side of things, our Collector guide and OTLP receiver walkthrough cover the receiving end in detail.

And if you want to understand how OpenTelemetry logging works at the protocol level, we have a deep dive on that too.

Authors
Ayooluwa Isaiah
Ayooluwa Isaiah