open-telemetry / open-telemetry/opentelemetry-python

Clarification: Implementation of custom LogRecordProcessor's

Open
#3,806 2 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

feature-request logging sdk
Dominant language
Python
Stars
2.6k
Forks
1k
Avg merge
4d 15h
Merged PRs (30d)
19

Description

Hey folks :)

I've been eyeballing this project and the otel specification for months, since sooner or later we want to replace our own telemetry sdks (build on tools like structlog and pydantic) in our distributed cloud MLOps platform with otel. I feel this is the right thing to do.

I know the logs sdk is still not stable, but I have started to do hands on experiments nonetheless, by recreating the otel provided docker compose stack with my own apps to get a feeling on how logs, metrics and traces are defined and utilised by otel. This helps me to already layout the roadmap for migrating to otel and prepare all user stories for this epic.

One thing that we do not want to change when moving to otel, is the way how our structured logs are defined, as that has proven of great value across all components of our platform (restful services, lambda functions, containerised apps, batch processing and training jobs, scheduler environments, realtime inference endpoints, etc.).

We currently model our log messages (or rather the log bodies) using pydantic, and emit them by passing instantiated pydantic models to our logging sdk, somewhat similar to this:

from pydantic import BaseModel

class JobInitialized(BaseModel):
    configuration: dict[str, str]

logger.info(JobInitialized(configuration={"foo": "bar"}))

This way of logging is established across all platform components and has been adopted by all people involved (Data Scientists, Data Engineers, etc.). It enables a very high degree of consistency and allows us to do advanced operations, like sending all of our logs to a data catalogue and query it via sql (to mention one use case).

After some hours of reading the docs, reverse engineering the logs sdk and iterating over my own docker compose stack, I feel like the correct approach to add support for pydantic based log messages, is using a LogRecordProcessor.

The following snipped roughly demonstrates what we might want to do:

import logging

from uuid import uuid4

from opentelemetry._logs import set_logger_provider
from opentelemetry.attributes import BoundedAttributes
from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter
from opentelemetry.sdk._logs import LogData
from opentelemetry.sdk._logs import LoggerProvider
from opentelemetry.sdk._logs import LoggingHandler
from opentelemetry.sdk._logs import LogRecordProcessor
from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
from opentelemetry.sdk.resources import Resource
from pydantic import BaseModel


class PydanticLogProcessor(LogRecordProcessor):
    def emit(self, log_data: LogData) -> LogData:
        """open question: is the return type annotation correct? mypy at least does not complain about it"""
        body = log_data.log_record.body
        attributes = log_data.log_record.attributes
        if isinstance(body, BaseModel):
            # serialize the model to a json serializable dict
            log_data.log_record.body = body.model_dump(mode="json")

            if isinstance(attributes, BoundedAttributes):
                # attach additional derived attributes
                updated_attributes = attributes.copy()
                updated_attributes.update({"log.uid": str(uuid4()), "log.type": body.__class__.__name__})
                log_data.log_record.attributes = BoundedAttributes(attributes=updated_attributes)
        return log_data

    def shutdown(self) -> None:
        """open question: clarify usage of shutdown on processors which only emit to the logging pipeline w/o export"""
        pass

    def force_flush(self, timeout_millis: int = 30000) -> None:
        """open question: clarify usage of force_flush on processors which only emit to the logging pipeline w/o export"""
        pass


logger_provider = LoggerProvider(
    resource=Resource.create(attributes={"service.namespace": "foo", "service.name": "bar"})
)
set_logger_provider(logger_provider)
processor_chain = [
    PydanticLogProcessor(),
    BatchLogRecordProcessor(OTLPLogExporter(insecure=True)),
]
for processor in processor_chain:
    logger_provider.add_log_record_processor(processor)
handler = LoggingHandler(level=logging.DEBUG, logger_provider=logger_provider)

# Attach OTLP handler to root logger
root_logger = logging.getLogger()
root_logger.setLevel(logging.DEBUG)
root_logger.addHandler(handler)


class JobInitialized(BaseModel):
    configuration: dict[str, str]


if __name__ == "__main__":
    logger = logging.getLogger("app")
    logger.info(JobInitialized(configuration={"job_name": "foo", "input": "foo/lorem", "output": "foo/ipsum"}))

If I spin up a local otel collector (similar to how it is configured by the logs sample in this repo), I do see an output like I would expect it:

Body: Map({"configuration":{"input":"foo/lorem","job_name":"foo","output":"foo/ipsum"}})
Attributes:
     -> code.filepath: Str(/foo/bar/simple.py)
     -> code.function: Str(<module>)
     -> code.lineno: Int(65)
     -> log.uid: Str(ce25f96f-325c-43b1-9f76-daea04fc54df)
     -> log.type: Str(JobInitialized)

This is great, but I do have some open questions:

  • is a LogRecordProcessor the right weapon of choice to accomplish this? (as of now, I do not see that logging.Formatters are already supported, and honestly, using a LogRecordProcessor also feels a lot cleaner then messing with the untyped interfaces of formatters 🙄 😬 )
  • is the return type annotation for above processor defined correctly? (the base LogRecordProcessor does not have a return type annotation at all in it's emit method)
  • are the shutdown and force_flush methods setup correctly for a log processors whose only purpose is to do some modifications to the log data and emit it to the pipeline?

Cheers and kind regards 🚀

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with docs/examples/logs/example.py and the LogRecordProcessor API shown in the issue. Review the processor lifecycle, emit return annotation, shutdown, and force_flush behavior, then document whether this is the supported approach and what the completed guidance should clarify.

Written by the indexing model from the issue text.

Assessment

Tech stack
python
Domain
observability
Issue type
Documentation
Difficulty
5/5
Estimated time
Over a week
Activity status
Stale
Clarity
Needs clarification
Newbie friendliness
20/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.