Repository navigation
feat: add OpenTelemetry logs - #3216
anish-sahoo wants to merge 2 commits into
Conversation
- Add observability.logs (bool or {enabled}) to cog.yaml parsing, schema,
and validation, reserve COG_LOGS_*, and set COG_LOGS_CONFIGURED/ENABLED
in generated and custom-Dockerfile images.
- Turn PythonObservabilityCheck into a function that checks the coglet
capability flag for each enabled signal.
- Export coglet's own tracing events from the parent through
opentelemetry-appender-tracing. The layer drops re-emitted model output
(coglet::setup, coglet::user, and coglet::prediction events with a
source field) and non-coglet targets. The worker does not export,
because its events already reach the parent.
- Install a Python LoggerProvider when logs are enabled, with a create_logger_provider(resource) factory in telemetry.py. - Export records by wrapping logging.Logger.callHandlers instead of adding a root handler, so logging.lastResort, basicConfig(), stderr, and the prediction logs field behave as before. Records follow the model's own levels, filters, and disabled loggers, and keep extra fields as attributes. - Reuse the SDK's attribute mapping and severity conversion without the deprecated LoggingHandler. Non-string messages stay structured, matching the SDK handler. - Skip opentelemetry loggers, re-entrant calls, and OTel SDK export threads so a failed export cannot queue more records. - Add a logs integration test, a custom logger provider in the hello-concurrency example, and logs docs.
There was a problem hiding this comment.
Review summary
This PR adds opt-in OpenTelemetry log export: the worker exports Python logging records by wrapping logging.Logger.callHandlers (no handler added, so stderr, the prediction logs field, lastResort, and basicConfig() are unchanged), and the parent exports Cog's own tracing events through opentelemetry-appender-tracing, filtering out re-emitted model stdout/stderr. Config, schema, Dockerfile generation, docs, and tests are all updated consistently, and the Go changes build and pass their focused tests.
I found one issue worth addressing (a test that verifies nothing), posted inline.
| config, | ||
| """ | ||
| from opentelemetry import trace | ||
| with trace.get_tracer("test").start_as_current_span("predict") as span: |
There was a problem hiding this comment.
These assertions are vacuous: the test can pass even if trace/span correlation into exported log records is completely broken.
_logs_script(..., traces=True) sets COG_TRACE_CONFIGURED=true but also OTEL_TRACES_EXPORTER=none, and _LOGS_CONFIG defines only create_logger_provider (no create_tracer_provider). install_providers() therefore installs no tracer provider, so trace.get_tracer("test").start_as_current_span("predict") uses the no-op tracer whose span context is invalid (trace_id == 0, span_id == 0). _export_log_record's context=get_current() then yields the same invalid IDs, so record.trace_id == span.get_span_context().trace_id is 0 == 0.
This is the only test covering the documented "records written during a prediction carry its trace and span IDs" behavior, and it would pass if, say, the context=get_current() argument were removed from _export_log_record. To make it meaningful, give the shared config a real tracer provider (the custom factory is used regardless of OTEL_TRACES_EXPORTER=none):
| with trace.get_tracer("test").start_as_current_span("predict") as span: | |
| _LOGS_CONFIG = """ | |
| from opentelemetry.sdk._logs import LoggerProvider | |
| from opentelemetry.sdk._logs.export import InMemoryLogRecordExporter, SimpleLogRecordProcessor | |
| from opentelemetry.sdk.trace import TracerProvider | |
| exporter = InMemoryLogRecordExporter() | |
| def create_logger_provider(resource): | |
| provider = LoggerProvider(resource=resource, shutdown_on_exit=False) | |
| provider.add_log_record_processor(SimpleLogRecordProcessor(exporter)) | |
| return provider | |
| def create_tracer_provider(resource): | |
| return TracerProvider(shutdown_on_exit=False) | |
| """ |
|
Review: 1 finding. One inline finding was posted on |
|
@michaeldwan can I get a review |
This adds opt-in OpenTelemetry log export to Cog. The Python worker exports records from the model's
loggingcalls, and the Rust parent exports Cog's own runtime events. Logs are off by default. Stderr, the predictionlogsfield, and coglet's structured stderr logs do not change, and exporter failures do not affect predictions.User experience
Enable logs in
cog.yaml:The boolean form
logs: trueis accepted as shorthand.Collector settings stay at runtime:
OTEL_EXPORTER_OTLP_LOGS_ENDPOINTandOTEL_EXPORTER_OTLP_LOGS_PROTOCOLoverride the generic settings. Generic HTTP endpoints receive/v1/logs; signal-specific endpoints are used as provided.COG_LOGS_ENABLED=falsedisables the signal at runtime, andOTEL_LOGS_EXPORTER=nonedisables only the built-in exporter.No model changes are required. Existing
loggingcalls are exported once the image opts in and an endpoint is present.Model logs
Each exported record keeps:
WARNINGandCRITICALmapped to OTel'sWARNandFATALcode.file.path,code.function.name, andcode.line.numberexception.type,exception.message, andexception.stacktracewhenexc_infois setextrafields as attributesA message that is a dict or list with no format arguments stays structured, matching the OpenTelemetry SDK handler. The docs recommend
extrafor fields people want to filter on, because backends differ in whether they index body fields.Cog does not change log levels. A record is exported only if the model's logging configuration lets it through, so with Python's defaults only
WARNINGand above are exported. Records from disabled loggers or removed by logger filters are not exported.How Python records are captured
Export wraps
logging.Logger.callHandlers. That method runs once per record, after the logger's level,disabled, and filter checks and before any handler. Cog installs the wrapper when the logger provider is installed and restores the original on shutdown.Cog does not add a root handler. A root handler would stop
logging.lastResortfrom printing unconfigured warnings to stderr, which would change the predictionlogsfield, and it would make a laterlogging.basicConfig()call a no-op. A record factory was also considered, but CPython appliesextraafter the factory runs, so those attributes would be lost.The SDK's
LoggingHandleris deprecated in 1.44.0, andopentelemetry-instrumentation-logging's instrumentor callsbasicConfig()itself, so neither is used. Records are converted with the SDK's own attribute mapping (theLoggingHandler._get_attributesstaticmethod, called without constructing the handler) andstd_to_otel. Both are private SDK APIs pinned at 1.44.0 and should be checked on the next SDK bump.Runtime logs
The parent attaches
opentelemetry-appender-tracing0.32.0to itstracingsubscriber and exports Cog's own events, such as prediction start and completion, setup progress, and errors. Event fields likeprediction_idbecome attributes.The parent re-emits model stdout and stderr as tracing events for its own stderr output. The logs layer filters those out:
coglet::setupandcoglet::userevents, andcoglet::predictionevents that carry asourcefield. Cog's owncoglet::predictionevents do not have that field. Non-coglettargets are also excluded.Worker runtime events already reach the parent over the control channel and are exported there, so the worker does not export them a second time. Relayed worker events keep only their message and have the event name
worker_log.What is not exported
print()output and raw stdout and stderr are not exported. They are noisy, duplicateloggingoutput when the model writes to stderr, and are already available in the prediction response.Lifecycle and failure behavior
The parent provider is created before the tracing subscriber and shut down after tracing and metrics, so their shutdown events are still captured. Shutdown alone drains the batch processor in both SDKs, so there is no flush step that could export a record twice.
Missing endpoints and invalid built-in exporter settings log a warning and leave serving unaffected. Invalid custom
create_logger_providerfactories fail setup, like the other factories.Records logged from OpenTelemetry loggers, re-entrant calls, and OTel SDK export threads are skipped. Without that, an exporter's HTTP or gRPC client logging a failed export during a collector outage would queue another record for export. The model's own HTTP client logs are still exported.
Custom Python telemetry
observability.configcan provide a logger provider:The factory is optional. A missing factory uses Cog's default provider, and Cog owns shutdown.
Build and configuration
observability.logsis added to config parsing, the JSON schema, and validation.observability.confignow accepts logs as its only enabled signal.COG_LOGS_*is reserved from thecog.yamlenvironmentlist.COG_LOGS_CONFIGUREDandCOG_LOGS_ENABLED. Custom-Dockerfile images set them explicitly totrueorfalseso a base image cannot pass on its logs settings.PythonObservabilityCheckis now a function that checks the coglet capability flag for each enabled signal. Coglet adds_supports_observability_logs.Documentation and examples
The observability guide has a new Logs section, and the YAML, environment, Python, architecture, and README references cover logs.
examples/hello-concurrencyenables logs, defines a custom logger provider with OTLP export and anOTEL_DEBUG_LOGSconsole exporter, and logs anextrafield.integration-tests/tests/observability_logs.txtarchecks that a custom logger provider is installed before model import, that a model warning is exported exactly once, and that it still appears in the prediction's logs.