Skip to content

Commit 7bc9f17

Browse files
Merge branch 'main' into fix-wsgi-invalid-request-uri
2 parents b980788 + f5adb4d commit 7bc9f17

13 files changed

Lines changed: 192 additions & 49 deletions

File tree

.github/workflows/generate_workflows_lib/src/generate_workflows_lib/misc.yml.j2

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,12 @@ jobs:
6868
- name: Install tox
6969
run: pip install tox-uv
7070

71+
{%- if job_data == "docker-tests" %}
72+
73+
- name: Install Microsoft SQL ODBC driver
74+
run: sudo apt update -y && ACCEPT_EULA=Y sudo apt install -y msodbcsql18 unixodbc-dev unixodbc
75+
{%- endif %}
76+
7177
- name: Run tests
7278
run: tox -e {{ job_data }}
7379
{%- if job_data == "generate-workflows" %}

.github/workflows/misc.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,9 @@ jobs:
5959
- name: Install tox
6060
run: pip install tox-uv
6161

62+
- name: Install Microsoft SQL ODBC driver
63+
run: sudo apt update -y && ACCEPT_EULA=Y sudo apt install -y msodbcsql18 unixodbc-dev unixodbc
64+
6265
- name: Run tests
6366
run: tox -e docker-tests
6467

CHANGELOG.md

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212
## Unreleased
1313

1414
### Added
15+
1516
- Add `BaggageLogProcessor` to `opentelemetry-processor-baggage`
1617
([#4371](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4371))
17-
1818
- `opentelemetry-instrumentation-system-metrics`: Add support for `process.disk.io` metric in system-metrics instrumentation
19-
([#4397](https://github.com/open-telemetry/opentelemetry-python-contrib/issues/4397))
19+
([#4397](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4397))
2020
- Switch to SPDX license headers and add CI enforcement
2121
([#4533](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4533))
2222
- Bump `pylint` to `4.0.5`
@@ -31,6 +31,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3131
([#3839](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3839))
3232
- `opentelemetry-exporter-richconsole`: Add support for suppressing resource information
3333
([#3898](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3898))
34+
- `opentelemetry-instrumentation`: Add experimental metrics attributes Labeler utility
35+
([#4288](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4288))
3436

3537
### Fixed
3638

@@ -46,13 +48,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
4648
([#4360](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4360))
4749
- `opentelemetry-instrumentation-aiohttp-server`: Use `canonical` attribute of the `Resource` as a span name
4850
([#3896](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3896))
51+
- `docker-tests`: Don't require sudo, debian based distro and MS SQL ODBC driver to run locally. Instead require docker and unixodbc
52+
([#4478](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4478))
4953
- Refactor unit tests to allow for population of the random trace id flag in the `traceparent` header
5054
([#4030](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4030))
55+
- `opentelemetry-instrumentation-fastapi`: Fix `FastAPI` instrumentation to correctly trace `BackgroundTasks` by wrapping their execution in a dedicated span, ensuring proper parent-child relationships and accurate trace timing
56+
([#4368](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4368))
57+
- `opentelemetry-instrumentation-celery`: Coerce timelimit values to strings in `set_attributes_from_context()` to prevent mixed-type span attribute warning
58+
([#4361](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4361))
59+
- `opentelemetry-instrumentation-mysqlclient`: Update unit tests to properly validate trace context trace flag values.
60+
([#4560](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4560))
5161

5262
### Breaking changes
5363

5464
- Drop Python 3.9 support
5565
([#4412](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4412))
66+
- `opentelemetry-instrumentation-logging`: Use `LogRecord.getMessage()` to format and extract each log record's body text to more closely match the expected usage of the logging system. As a result, all OTel log record bodies are now always strings.
67+
Previously, if `LogRecord.msg` (which contains the format string) was set to a non-string object (e.g. `logger.warning(some_dict)`), the object was exported as-is to the OTLP body field. Now, `LogRecord.getMessage()` will convert it to to a string.
68+
If you are passing in non-strings as the format string argument and your backend is expecting them as-is, you will need to update accordingly.
69+
([#4372](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4372))
5670

5771
## Version 1.41.0/0.62b0 (2026-04-09)
5872

@@ -72,13 +86,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7286
([#4049](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4049))
7387
- `opentelemetry-instrumentation-sqlalchemy`: implement new semantic convention opt-in migration
7488
([#4110](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4110))
75-
- `opentelemetry-instrumentation`: Add experimental metrics attributes Labeler utility
76-
([#4288](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4288))
7789

7890
### Fixed
7991

80-
- `opentelemetry-instrumentation-celery`: Coerce timelimit values to strings in `set_attributes_from_context()` to prevent mixed-type span attribute warning
81-
([#4361](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4361))
8292
- `opentelemetry-docker-tests`: Replace deprecated `SpanAttributes` from `opentelemetry.semconv.trace` with `opentelemetry.semconv._incubating.attributes`
8393
([#4339](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4339))
8494
- `opentelemetry-instrumentation-confluent-kafka`: Skip `recv` span creation when `poll()` returns no message or `consume()` returns an empty list, avoiding empty spans on idle polls

instrumentation/opentelemetry-instrumentation-fastapi/src/opentelemetry/instrumentation/fastapi/__init__.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,7 @@ def client_response_hook(span: Span, scope: dict[str, Any], message: dict[str, A
179179

180180
import fastapi
181181
from starlette.applications import Starlette
182+
from starlette.background import BackgroundTask
182183
from starlette.middleware.errors import ServerErrorMiddleware
183184
from starlette.routing import Match, Route
184185
from starlette.types import ASGIApp, Receive, Scope, Send
@@ -388,6 +389,16 @@ async def __call__(
388389
app,
389390
)
390391

392+
if not hasattr(BackgroundTask, "_otel_original_call"):
393+
BackgroundTask._otel_original_call = BackgroundTask.__call__
394+
395+
async def traced_call(self):
396+
span_name = f"BackgroundTask {getattr(self.func, '__name__', self.func.__class__.__name__)}"
397+
with tracer.start_as_current_span(span_name):
398+
return await BackgroundTask._otel_original_call(self)
399+
400+
BackgroundTask.__call__ = traced_call
401+
391402
app._is_instrumented_by_opentelemetry = True
392403
if app not in _InstrumentedFastAPI._instrumented_fastapi_apps:
393404
_InstrumentedFastAPI._instrumented_fastapi_apps.add(app)
@@ -405,6 +416,11 @@ def uninstrument_app(app: fastapi.FastAPI):
405416
app.build_middleware_stack = original_build_middleware_stack
406417
del app._original_build_middleware_stack
407418
app.middleware_stack = app.build_middleware_stack()
419+
420+
if hasattr(BackgroundTask, "_otel_original_call"):
421+
BackgroundTask.__call__ = BackgroundTask._otel_original_call
422+
del BackgroundTask._otel_original_call
423+
408424
app._is_instrumented_by_opentelemetry = False
409425

410426
# Remove the app from the set of instrumented apps to avoid calling uninstrument twice

instrumentation/opentelemetry-instrumentation-fastapi/tests/test_fastapi_instrumentation.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,13 @@
1414

1515
import fastapi
1616
import pytest
17+
from fastapi.background import BackgroundTasks
1718
from fastapi.middleware.asyncexitstack import AsyncExitStackMiddleware
1819
from fastapi.middleware.httpsredirect import HTTPSRedirectMiddleware
1920
from fastapi.responses import JSONResponse, PlainTextResponse
2021
from fastapi.routing import APIRoute
2122
from fastapi.testclient import TestClient
23+
from starlette.background import BackgroundTask
2224
from starlette.routing import Match
2325
from starlette.types import Receive, Scope, Send
2426

@@ -482,6 +484,51 @@ def test_basic_fastapi_call(self):
482484
for span in spans:
483485
self.assertIn("GET /foobar", span.name)
484486

487+
def test_background_task_span_parents_inner_spans(self):
488+
"""Regression test for #4251: spans created inside a FastAPI
489+
BackgroundTask must be children of a dedicated background-task span
490+
instead of the already-closed request span."""
491+
self.memory_exporter.clear()
492+
app = fastapi.FastAPI()
493+
self._instrumentor.instrument_app(app)
494+
tracer = self.tracer_provider.get_tracer(__name__)
495+
496+
async def background_notify():
497+
with tracer.start_as_current_span("inside-background-task"):
498+
pass
499+
500+
@app.post("/checkout")
501+
async def checkout(background_tasks: BackgroundTasks):
502+
background_tasks.add_task(background_notify)
503+
return {"status": "processing"}
504+
505+
with TestClient(app) as client:
506+
response = client.post("/checkout")
507+
self.assertEqual(200, response.status_code)
508+
spans = self.memory_exporter.get_finished_spans()
509+
request_span = next(
510+
span for span in spans if span.name == "POST /checkout"
511+
)
512+
background_span = next(
513+
span
514+
for span in spans
515+
if span.name == "BackgroundTask background_notify"
516+
)
517+
inner_span = next(
518+
span for span in spans if span.name == "inside-background-task"
519+
)
520+
self.assertIsNotNone(background_span.parent)
521+
self.assertEqual(
522+
background_span.parent.span_id,
523+
request_span.context.span_id,
524+
)
525+
self.assertIsNotNone(inner_span.parent)
526+
self.assertEqual(
527+
inner_span.parent.span_id,
528+
background_span.context.span_id,
529+
)
530+
otel_fastapi.FastAPIInstrumentor().uninstrument_app(app)
531+
485532
def test_fastapi_route_attribute_added(self):
486533
"""Ensure that fastapi routes are used as the span name."""
487534
self._client.get("/user/123")
@@ -977,6 +1024,49 @@ def test_basic_post_request_metric_success_both_semconv(self):
9771024
if isinstance(point, NumberDataPoint):
9781025
self.assertEqual(point.value, 0)
9791026

1027+
def test_uninstrument_app_restores_background_task_call(self):
1028+
"""Regression test for #4251: uninstrumentation must restore the
1029+
original BackgroundTask.__call__ after FastAPI patches it."""
1030+
self.assertTrue(hasattr(BackgroundTask, "_otel_original_call"))
1031+
self._instrumentor.uninstrument_app(self._app)
1032+
self.assertFalse(hasattr(BackgroundTask, "_otel_original_call"))
1033+
1034+
def test_background_task_span_not_duplicated_on_double_instrument_app(
1035+
self,
1036+
):
1037+
"""Regression test for #4251: repeated instrument_app calls must not
1038+
wrap BackgroundTask.__call__ multiple times or duplicate spans."""
1039+
self.memory_exporter.clear()
1040+
app = fastapi.FastAPI()
1041+
self._instrumentor.instrument_app(app)
1042+
self._instrumentor.instrument_app(app)
1043+
tracer = self.tracer_provider.get_tracer(__name__)
1044+
1045+
async def background_notify():
1046+
with tracer.start_as_current_span("inside-background-task"):
1047+
pass
1048+
1049+
@app.post("/checkout")
1050+
async def checkout(background_tasks: BackgroundTasks):
1051+
background_tasks.add_task(background_notify)
1052+
return {"status": "processing"}
1053+
1054+
with TestClient(app) as client:
1055+
response = client.post("/checkout")
1056+
self.assertEqual(200, response.status_code)
1057+
spans = self.memory_exporter.get_finished_spans()
1058+
background_spans = [
1059+
span
1060+
for span in spans
1061+
if span.name == "BackgroundTask background_notify"
1062+
]
1063+
inner_spans = [
1064+
span for span in spans if span.name == "inside-background-task"
1065+
]
1066+
self.assertEqual(len(background_spans), 1)
1067+
self.assertEqual(len(inner_spans), 1)
1068+
otel_fastapi.FastAPIInstrumentor().uninstrument_app(app)
1069+
9801070
def test_metric_uninstrument_app(self):
9811071
self._client.get("/foobar")
9821072
self._instrumentor.uninstrument_app(self._app)

instrumentation/opentelemetry-instrumentation-logging/src/opentelemetry/instrumentation/logging/handler.py

Lines changed: 1 addition & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@
1919
get_logger,
2020
get_logger_provider,
2121
)
22-
from opentelemetry.attributes import _VALID_ANY_VALUE_TYPES
2322
from opentelemetry.context import get_current
2423
from opentelemetry.semconv._incubating.attributes import code_attributes
2524
from opentelemetry.semconv.attributes import exception_attributes
@@ -169,25 +168,7 @@ def _translate(self, record: logging.LogRecord) -> LogRecord:
169168
if self.formatter:
170169
body = self.format(record)
171170
else:
172-
# `record.getMessage()` uses `record.msg` as a template to format
173-
# `record.args` into. There is a special case in `record.getMessage()`
174-
# where it will only attempt formatting if args are provided,
175-
# otherwise, it just stringifies `record.msg`.
176-
#
177-
# Since the OTLP body field has a type of 'any' and the logging module
178-
# is sometimes used in such a way that objects incorrectly end up
179-
# set as record.msg, in those cases we would like to bypass
180-
# `record.getMessage()` completely and set the body to the object
181-
# itself instead of its string representation.
182-
# For more background, see: https://github.com/open-telemetry/opentelemetry-python/pull/4216
183-
if not record.args and not isinstance(record.msg, str):
184-
# if record.msg is not a value we can export, cast it to string
185-
if not isinstance(record.msg, _VALID_ANY_VALUE_TYPES):
186-
body = str(record.msg)
187-
else:
188-
body = record.msg
189-
else:
190-
body = record.getMessage()
171+
body = record.getMessage()
191172

192173
# Map Python log level names to OTel severity text as defined in
193174
# https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/logs/data-model.md#displaying-severity

instrumentation/opentelemetry-instrumentation-logging/tests/test_handler.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -441,6 +441,44 @@ def test_log_body_is_always_string_with_formatter(self):
441441

442442
logger.removeHandler(handler)
443443

444+
def test_simple_log_record_processor_custom_single_obj(self):
445+
"""
446+
Tests that logging a single non-string object uses getMessage
447+
"""
448+
processor, logger, handler = set_up_test_logging(logging.WARNING)
449+
450+
# NOTE: the behaviour of `record.getMessage` is detailed in the
451+
# `logging.Logger.debug` documentation:
452+
# > The msg is the message format string, and the args are the arguments
453+
# > which are merged into msg using the string formatting operator. [...]
454+
# > No % formatting operation is performed on msg when no args are supplied.
455+
456+
# This test uses the presence of '%s' in the first arg to determine if
457+
# formatting was applied
458+
459+
# string msg with no args - getMessage bypasses formatting and sets the string directly
460+
logger.warning("a string with a percent-s: %s") # pylint: disable=logging-too-few-args
461+
462+
# string msg with args - getMessage formats args into the msg
463+
logger.warning("a string with a percent-s: %s", "and arg")
464+
# non-string msg with args - getMessage stringifies msg and formats args into it
465+
logger.warning(["a non-string with a percent-s", "%s"], "and arg")
466+
# non-string msg with no args - getMessage stringifies the object and bypasses formatting
467+
logger.warning(["a non-string with a percent-s", "%s"])
468+
469+
logger.removeHandler(handler)
470+
471+
assert processor.emit_count() == 4
472+
expected = [
473+
("a string with a percent-s: %s"),
474+
("a string with a percent-s: and arg"),
475+
("['a non-string with a percent-s', 'and arg']"),
476+
("['a non-string with a percent-s', '%s']"),
477+
]
478+
for index, msg in enumerate(expected):
479+
record = processor.get_log_record(index)
480+
self.assertEqual(record.log_record.body, msg)
481+
444482
@patch.dict(os.environ, {"OTEL_SDK_DISABLED": "true"})
445483
def test_handler_root_logger_with_disabled_sdk_does_not_go_into_recursion_error(
446484
self,

instrumentation/opentelemetry-instrumentation-mysqlclient/tests/test_mysqlclient_integration.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -143,9 +143,10 @@ def test_instrument_connection_with_dbapi_sqlcomment_enabled(self):
143143
span = spans_list[0]
144144
span_id = format(span.get_span_context().span_id, "016x")
145145
trace_id = format(span.get_span_context().trace_id, "032x")
146+
trace_flags = format(span.get_span_context().trace_flags, "02x")
146147
self.assertEqual(
147148
mock_cursor.execute.call_args[0][0],
148-
f"Select 1 /*db_driver='MySQLdb%%3Afoobar',dbapi_level='123',dbapi_threadsafety='123',driver_paramstyle='test',mysql_client_version='foobaz',traceparent='00-{trace_id}-{span_id}-01'*/;",
149+
f"Select 1 /*db_driver='MySQLdb%%3Afoobar',dbapi_level='123',dbapi_threadsafety='123',driver_paramstyle='test',mysql_client_version='foobaz',traceparent='00-{trace_id}-{span_id}-{trace_flags}'*/;",
149150
)
150151
self.assertEqual(
151152
span.attributes[DB_STATEMENT],
@@ -194,7 +195,7 @@ def test_instrument_connection_with_dbapi_sqlcomment_enabled_stmt_enabled(
194195
)
195196
self.assertEqual(
196197
span.attributes[DB_STATEMENT],
197-
f"Select 1 /*db_driver='MySQLdb%%3Afoobar',dbapi_level='123',dbapi_threadsafety='123',driver_paramstyle='test',mysql_client_version='foobaz',traceparent='00-{trace_id}-{span_id}-01'*/;",
198+
f"Select 1 /*db_driver='MySQLdb%%3Afoobar',dbapi_level='123',dbapi_threadsafety='123',driver_paramstyle='test',mysql_client_version='foobaz',traceparent='00-{trace_id}-{span_id}-{trace_flags}'*/;",
198199
)
199200

200201
def test_instrument_connection_with_dbapi_sqlcomment_enabled_with_options(
@@ -391,7 +392,7 @@ def test_instrument_with_dbapi_sqlcomment_enabled_stmt_enabled(
391392
)
392393
self.assertEqual(
393394
span.attributes[DB_STATEMENT],
394-
f"Select 1 /*db_driver='MySQLdb%%3Afoobar',dbapi_level='123',dbapi_threadsafety='123',driver_paramstyle='test',mysql_client_version='foobaz',traceparent='00-{trace_id}-{span_id}-01'*/;",
395+
f"Select 1 /*db_driver='MySQLdb%%3Afoobar',dbapi_level='123',dbapi_threadsafety='123',driver_paramstyle='test',mysql_client_version='foobaz',traceparent='00-{trace_id}-{span_id}-{trace_flags}'*/;",
395396
)
396397

397398
def test_instrument_with_dbapi_sqlcomment_enabled_with_options(

tests/opentelemetry-docker-tests/tests/check_availability.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,8 +130,11 @@ def check_docker_services_availability():
130130
check_mysql_connection()
131131
check_postgres_connection()
132132
check_redis_connection()
133-
check_mssql_connection()
134-
setup_mssql_db()
133+
134+
# make accepting EULA for ms sql odbc driver optional
135+
if "ODBC Driver 18 for SQL Server" in pyodbc.drivers():
136+
check_mssql_connection()
137+
setup_mssql_db()
135138

136139

137140
check_docker_services_availability()

tests/opentelemetry-docker-tests/tests/docker-compose.yml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
version: '3'
2-
31
services:
42
otmongo:
53
ports:

0 commit comments

Comments
 (0)