Skip to content

Commit 4c55894

Browse files
internal comments
1 parent ae5de4e commit 4c55894

3 files changed

Lines changed: 51 additions & 32 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1717
([#4244](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4244))
1818
- `opentelemetry-instrumentation-sqlite3`: Add uninstrument, error status, suppress, and no-op tests
1919
([#4335](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4335))
20+
- `opentelemetry-instrumentation-structlog`: Add new package providing `StructlogHandler` and `StructlogInstrumentor` to bridge structlog into the OpenTelemetry Logs SDK pipeline with trace context correlation and exception capture. `opentelemetry-instrumentation`: Add shared `log_utils`
21+
([#4286](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4286))
2022

2123
### Fixed
2224

@@ -288,8 +290,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
288290
([#3912](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3912))
289291

290292
### Added
291-
- `opentelemetry-instrumentation-structlog`: Add new package providing `StructlogHandler` and `StructlogInstrumentor` to bridge structlog into the OpenTelemetry Logs SDK pipeline with trace context correlation and exception capture.
292-
([#4286](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4286))
293293
- `opentelemetry-instrumentation-botocore`: Add support for AWS Secrets Manager semantic convention attribute
294294
([#3765](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3765))
295295
- `opentelemetry-instrumentation-dbapi`: Add support for `commenter_options` in `trace_integration` function to control SQLCommenter behavior

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

Lines changed: 48 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,8 @@
3131
"""
3232

3333
import sys
34-
import threading
3534
import traceback
35+
from datetime import datetime, timezone
3636
from time import time_ns
3737
from typing import Any, Callable, Collection, Optional
3838

@@ -77,6 +77,37 @@
7777
"fatal": 50,
7878
}
7979

80+
# Map structlog level names to OTel canonical severity text where they differ
81+
_STRUCTLOG_TO_OTEL_SEVERITY_TEXT = {
82+
"warning": "WARN",
83+
"critical": "FATAL",
84+
"fatal": "FATAL",
85+
}
86+
87+
88+
def _parse_structlog_timestamp(value: Any) -> Optional[int]:
89+
"""
90+
Convert a structlog timestamp value to nanoseconds since epoch, or None.
91+
92+
structlog's TimeStamper emits either a float (UNIX seconds, the default)
93+
or a string (ISO 8601 when fmt="iso", or a strftime pattern otherwise).
94+
We handle float and ISO 8601; anything else returns None so the SDK can
95+
fill in the observed time.
96+
"""
97+
if value is None:
98+
return None
99+
if isinstance(value, (int, float)):
100+
return int(value * 1e9)
101+
if isinstance(value, str):
102+
try:
103+
dt = datetime.fromisoformat(value)
104+
if dt.tzinfo is None:
105+
dt = dt.replace(tzinfo=timezone.utc)
106+
return int(dt.timestamp() * 1e9)
107+
except ValueError:
108+
return None
109+
return None
110+
80111

81112
class StructlogHandler:
82113
"""
@@ -185,20 +216,23 @@ def _translate(self, event_dict: dict) -> LogRecord:
185216
Returns:
186217
An OpenTelemetry LogRecord.
187218
"""
188-
# Use current time for both timestamp and observed_timestamp
189-
# structlog's timestamps are unreliable/varied depending on configuration
190-
timestamp = observed_timestamp = time_ns()
219+
# observed_timestamp is when the SDK received the event (always now).
220+
# timestamp is when the event occurred; use the structlog "timestamp"
221+
# field if present and parseable (UNIX float or ISO 8601 string),
222+
# otherwise leave as None and let the SDK fill it in.
223+
observed_timestamp = time_ns()
224+
timestamp = _parse_structlog_timestamp(event_dict.get("timestamp"))
191225

192226
# Get the log level and map to OTel severity
193227
level_str = event_dict.get("level", "info")
194228
levelno = _STRUCTLOG_LEVEL_TO_LEVELNO.get(level_str.lower(), 20)
195229
severity_number = std_to_otel(levelno)
196230

197-
# Normalize severity text: "warning" -> "WARN", otherwise uppercase
198-
if level_str.lower() == "warning":
199-
severity_text = "WARN"
200-
else:
201-
severity_text = level_str.upper()
231+
# Normalize severity text to OTel canonical names where structlog
232+
# level names differ: "warning" -> "WARN", "critical"/"fatal" -> "FATAL"
233+
severity_text = _STRUCTLOG_TO_OTEL_SEVERITY_TEXT.get(
234+
level_str.lower(), level_str.upper()
235+
)
202236

203237
# Get the message body
204238
body = event_dict.get("event")
@@ -222,15 +256,11 @@ def _translate(self, event_dict: dict) -> LogRecord:
222256
def flush(self) -> None:
223257
"""
224258
Flush the logger provider.
225-
226-
This method flushes any pending logs. It runs in a separate thread
227-
to avoid potential deadlocks.
228259
"""
229260
if hasattr(self._logger_provider, "force_flush") and callable(
230261
self._logger_provider.force_flush
231262
):
232-
thread = threading.Thread(target=self._logger_provider.force_flush)
233-
thread.start()
263+
self._logger_provider.force_flush()
234264

235265

236266
class StructlogInstrumentor(BaseInstrumentor):
@@ -248,7 +278,7 @@ class StructlogInstrumentor(BaseInstrumentor):
248278
>>> logger.info("hello", user="alice")
249279
"""
250280

251-
_processor = None
281+
_processor: Optional["StructlogHandler"] = None
252282
_original_configure: Optional[Callable] = None
253283

254284
def instrumentation_dependencies(self) -> Collection[str]:
@@ -303,21 +333,10 @@ def _instrument(self, **kwargs):
303333
# instrumentation, the handler is re-inserted into the new chain.
304334
StructlogInstrumentor._original_configure = structlog.configure
305335

306-
def _patched_configure(*args, **kwargs):
336+
def _patched_configure(**kwargs):
307337
# If the user is supplying a processors list, ensure our handler
308338
# is included before passing it to the original configure.
309-
# processors may be passed as the first positional arg or as a kwarg.
310-
if args:
311-
processors = list(args[0])
312-
if not any(
313-
isinstance(p, StructlogHandler) for p in processors
314-
):
315-
insert_position = max(len(processors) - 1, 0)
316-
processors.insert(
317-
insert_position, StructlogInstrumentor._processor
318-
)
319-
args = (processors,) + args[1:]
320-
elif "processors" in kwargs:
339+
if "processors" in kwargs:
321340
processors = list(kwargs["processors"])
322341
if not any(
323342
isinstance(p, StructlogHandler) for p in processors
@@ -329,7 +348,7 @@ def _patched_configure(*args, **kwargs):
329348
kwargs["processors"] = processors
330349
original = StructlogInstrumentor._original_configure
331350
if original is not None:
332-
return original(*args, **kwargs)
351+
return original(**kwargs)
333352
return None
334353

335354
structlog.configure = _patched_configure

instrumentation/opentelemetry-instrumentation-structlog/tests/test_structlog.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ def test_critical_level(self):
132132
self.assertEqual(len(logs), 1)
133133

134134
log = logs[0]
135-
self.assertEqual(log.log_record.severity_text, "CRITICAL")
135+
self.assertEqual(log.log_record.severity_text, "FATAL")
136136
self.assertEqual(log.log_record.severity_number, SeverityNumber.FATAL)
137137

138138
def test_exception_from_exc_info_tuple(self):

0 commit comments

Comments
 (0)