iii-observability 0.13.0.dev1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- iii_observability-0.13.0.dev1/.gitignore +92 -0
- iii_observability-0.13.0.dev1/PKG-INFO +34 -0
- iii_observability-0.13.0.dev1/README.md +5 -0
- iii_observability-0.13.0.dev1/pyproject.toml +60 -0
- iii_observability-0.13.0.dev1/src/iii_observability/__init__.py +58 -0
- iii_observability-0.13.0.dev1/src/iii_observability/baggage_span_processor.py +42 -0
- iii_observability-0.13.0.dev1/src/iii_observability/http_instrumentation.py +102 -0
- iii_observability-0.13.0.dev1/src/iii_observability/logger.py +184 -0
- iii_observability-0.13.0.dev1/src/iii_observability/payload.py +92 -0
- iii_observability-0.13.0.dev1/src/iii_observability/py.typed +0 -0
- iii_observability-0.13.0.dev1/src/iii_observability/reconnection.py +24 -0
- iii_observability-0.13.0.dev1/src/iii_observability/span_ops.py +38 -0
- iii_observability-0.13.0.dev1/src/iii_observability/telemetry.py +592 -0
- iii_observability-0.13.0.dev1/src/iii_observability/telemetry_exporters.py +457 -0
- iii_observability-0.13.0.dev1/src/iii_observability/telemetry_types.py +46 -0
- iii_observability-0.13.0.dev1/tests/__init__.py +0 -0
- iii_observability-0.13.0.dev1/tests/test_http_instrumentation.py +22 -0
- iii_observability-0.13.0.dev1/tests/test_telemetry.py +17 -0
- iii_observability-0.13.0.dev1/uv.lock +752 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# C extensions
|
|
7
|
+
*.so
|
|
8
|
+
|
|
9
|
+
# Distribution / packaging
|
|
10
|
+
.Python
|
|
11
|
+
build/
|
|
12
|
+
develop-eggs/
|
|
13
|
+
dist/
|
|
14
|
+
downloads/
|
|
15
|
+
eggs/
|
|
16
|
+
.eggs/
|
|
17
|
+
lib/
|
|
18
|
+
lib64/
|
|
19
|
+
parts/
|
|
20
|
+
sdist/
|
|
21
|
+
var/
|
|
22
|
+
wheels/
|
|
23
|
+
*.egg-info/
|
|
24
|
+
.installed.cfg
|
|
25
|
+
*.egg
|
|
26
|
+
|
|
27
|
+
# PyInstaller
|
|
28
|
+
*.manifest
|
|
29
|
+
*.spec
|
|
30
|
+
|
|
31
|
+
# Installer logs
|
|
32
|
+
pip-log.txt
|
|
33
|
+
pip-delete-this-directory.txt
|
|
34
|
+
|
|
35
|
+
# Unit test / coverage reports
|
|
36
|
+
htmlcov/
|
|
37
|
+
.tox/
|
|
38
|
+
.nox/
|
|
39
|
+
.coverage
|
|
40
|
+
.coverage.*
|
|
41
|
+
.cache
|
|
42
|
+
nosetests.xml
|
|
43
|
+
coverage.xml
|
|
44
|
+
*.cover
|
|
45
|
+
*.py,cover
|
|
46
|
+
.hypothesis/
|
|
47
|
+
.pytest_cache/
|
|
48
|
+
pytest_cache/
|
|
49
|
+
|
|
50
|
+
# Translations
|
|
51
|
+
*.mo
|
|
52
|
+
*.pot
|
|
53
|
+
|
|
54
|
+
# Environments
|
|
55
|
+
.env
|
|
56
|
+
.venv
|
|
57
|
+
env/
|
|
58
|
+
venv/
|
|
59
|
+
ENV/
|
|
60
|
+
env.bak/
|
|
61
|
+
venv.bak/
|
|
62
|
+
|
|
63
|
+
# Spyder project settings
|
|
64
|
+
.spyderproject
|
|
65
|
+
.spyproject
|
|
66
|
+
|
|
67
|
+
# Rope project settings
|
|
68
|
+
.ropeproject
|
|
69
|
+
|
|
70
|
+
# mkdocs documentation
|
|
71
|
+
/site
|
|
72
|
+
|
|
73
|
+
# mypy
|
|
74
|
+
.mypy_cache/
|
|
75
|
+
.dmypy.json
|
|
76
|
+
dmypy.json
|
|
77
|
+
|
|
78
|
+
# Pyre type checker
|
|
79
|
+
.pyre/
|
|
80
|
+
|
|
81
|
+
# pytype static type analyzer
|
|
82
|
+
.pytype/
|
|
83
|
+
|
|
84
|
+
# Cython debug symbols
|
|
85
|
+
cython_debug/
|
|
86
|
+
|
|
87
|
+
# IDE
|
|
88
|
+
.idea/
|
|
89
|
+
.vscode/
|
|
90
|
+
*.swp
|
|
91
|
+
*.swo
|
|
92
|
+
*~
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: iii-observability
|
|
3
|
+
Version: 0.13.0.dev1
|
|
4
|
+
Summary: OpenTelemetry and logging primitives shared across iii SDKs.
|
|
5
|
+
Project-URL: Homepage, https://github.com/iii-hq/iii
|
|
6
|
+
Project-URL: Repository, https://github.com/iii-hq/iii
|
|
7
|
+
Author: III
|
|
8
|
+
License: Apache-2.0
|
|
9
|
+
Keywords: iii,logger,observability,opentelemetry
|
|
10
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Requires-Dist: httpx>=0.27
|
|
18
|
+
Requires-Dist: opentelemetry-api>=1.25
|
|
19
|
+
Requires-Dist: opentelemetry-sdk>=1.25
|
|
20
|
+
Requires-Dist: websockets>=12.0
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: mypy>=1.8; extra == 'dev'
|
|
23
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
24
|
+
Requires-Dist: pytest-cov>=6.0; extra == 'dev'
|
|
25
|
+
Requires-Dist: pytest-httpx>=0.30; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
27
|
+
Requires-Dist: ruff>=0.2; extra == 'dev'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# iii-observability
|
|
31
|
+
|
|
32
|
+
OpenTelemetry + Logger primitives shared across the iii SDKs.
|
|
33
|
+
|
|
34
|
+
See https://github.com/iii-hq/iii for the full project.
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "iii-observability"
|
|
7
|
+
version = "0.13.0.dev1"
|
|
8
|
+
description = "OpenTelemetry and logging primitives shared across iii SDKs."
|
|
9
|
+
authors = [{ name = "III" }]
|
|
10
|
+
license = { text = "Apache-2.0" }
|
|
11
|
+
readme = "README.md"
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
keywords = ["iii", "observability", "opentelemetry", "logger"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3.10",
|
|
17
|
+
"Programming Language :: Python :: 3.11",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
"License :: OSI Approved :: Apache Software License",
|
|
20
|
+
"Operating System :: OS Independent",
|
|
21
|
+
]
|
|
22
|
+
dependencies = [
|
|
23
|
+
"opentelemetry-api>=1.25",
|
|
24
|
+
"opentelemetry-sdk>=1.25",
|
|
25
|
+
"httpx>=0.27",
|
|
26
|
+
"websockets>=12.0",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.urls]
|
|
30
|
+
Homepage = "https://github.com/iii-hq/iii"
|
|
31
|
+
Repository = "https://github.com/iii-hq/iii"
|
|
32
|
+
|
|
33
|
+
[project.optional-dependencies]
|
|
34
|
+
dev = [
|
|
35
|
+
"pytest>=8.0",
|
|
36
|
+
"pytest-asyncio>=0.23",
|
|
37
|
+
"pytest-cov>=6.0",
|
|
38
|
+
"pytest-httpx>=0.30",
|
|
39
|
+
"mypy>=1.8",
|
|
40
|
+
"ruff>=0.2",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
[tool.hatch.build.targets.wheel]
|
|
44
|
+
packages = ["src/iii_observability"]
|
|
45
|
+
|
|
46
|
+
[tool.ruff]
|
|
47
|
+
line-length = 120
|
|
48
|
+
target-version = "py310"
|
|
49
|
+
|
|
50
|
+
[tool.ruff.lint]
|
|
51
|
+
select = ["E", "F", "I", "W"]
|
|
52
|
+
|
|
53
|
+
[tool.mypy]
|
|
54
|
+
python_version = "3.10"
|
|
55
|
+
strict = true
|
|
56
|
+
|
|
57
|
+
[tool.pytest.ini_options]
|
|
58
|
+
addopts = "--cov=src/iii_observability --cov-branch --cov-report=term-missing"
|
|
59
|
+
testpaths = ["tests"]
|
|
60
|
+
asyncio_mode = "auto"
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""iii-observability: shared OTel + Logger primitives."""
|
|
2
|
+
|
|
3
|
+
from .baggage_span_processor import DEFAULT_ALLOWLIST, BaggageSpanProcessor
|
|
4
|
+
from .http_instrumentation import execute_traced_request
|
|
5
|
+
from .logger import Logger
|
|
6
|
+
from .payload import (
|
|
7
|
+
REDACTED_PLACEHOLDER,
|
|
8
|
+
redact,
|
|
9
|
+
redact_and_truncate,
|
|
10
|
+
resolve_max_bytes_from_env,
|
|
11
|
+
)
|
|
12
|
+
from .reconnection import ReconnectionConfig
|
|
13
|
+
from .span_ops import (
|
|
14
|
+
current_span_is_recording,
|
|
15
|
+
record_span_event,
|
|
16
|
+
set_current_span_attribute,
|
|
17
|
+
set_current_span_error,
|
|
18
|
+
)
|
|
19
|
+
from .telemetry import (
|
|
20
|
+
current_span_id,
|
|
21
|
+
current_trace_id,
|
|
22
|
+
extract_baggage,
|
|
23
|
+
extract_traceparent,
|
|
24
|
+
flush_otel,
|
|
25
|
+
init_otel,
|
|
26
|
+
inject_baggage,
|
|
27
|
+
inject_traceparent,
|
|
28
|
+
shutdown_otel,
|
|
29
|
+
with_span,
|
|
30
|
+
)
|
|
31
|
+
from .telemetry_types import OtelConfig
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"BaggageSpanProcessor",
|
|
35
|
+
"DEFAULT_ALLOWLIST",
|
|
36
|
+
"Logger",
|
|
37
|
+
"OtelConfig",
|
|
38
|
+
"REDACTED_PLACEHOLDER",
|
|
39
|
+
"ReconnectionConfig",
|
|
40
|
+
"current_span_id",
|
|
41
|
+
"current_span_is_recording",
|
|
42
|
+
"current_trace_id",
|
|
43
|
+
"execute_traced_request",
|
|
44
|
+
"extract_baggage",
|
|
45
|
+
"extract_traceparent",
|
|
46
|
+
"flush_otel",
|
|
47
|
+
"init_otel",
|
|
48
|
+
"inject_baggage",
|
|
49
|
+
"inject_traceparent",
|
|
50
|
+
"record_span_event",
|
|
51
|
+
"redact",
|
|
52
|
+
"redact_and_truncate",
|
|
53
|
+
"resolve_max_bytes_from_env",
|
|
54
|
+
"set_current_span_attribute",
|
|
55
|
+
"set_current_span_error",
|
|
56
|
+
"shutdown_otel",
|
|
57
|
+
"with_span",
|
|
58
|
+
]
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Baggage -> span attribute processor."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Sequence
|
|
6
|
+
|
|
7
|
+
from opentelemetry import baggage
|
|
8
|
+
from opentelemetry.context import Context
|
|
9
|
+
from opentelemetry.sdk.trace import ReadableSpan, Span, SpanProcessor
|
|
10
|
+
|
|
11
|
+
#: DEFAULT_ALLOWLIST drift across languages would break worker chains;
|
|
12
|
+
#: lockstep tests in each SDK pin this constant at CI time.
|
|
13
|
+
DEFAULT_ALLOWLIST: tuple[str, ...] = (
|
|
14
|
+
"iii.session.id",
|
|
15
|
+
"iii.message.id",
|
|
16
|
+
"iii.function.id",
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class BaggageSpanProcessor(SpanProcessor):
|
|
21
|
+
|
|
22
|
+
def __init__(self, allowlist: Sequence[str] = DEFAULT_ALLOWLIST) -> None:
|
|
23
|
+
self._allowlist: tuple[str, ...] = tuple(allowlist)
|
|
24
|
+
|
|
25
|
+
def on_start(self, span: Span, parent_context: Context | None = None) -> None:
|
|
26
|
+
# NoOp guard: skip allocation when sampler drops the span.
|
|
27
|
+
if not span.is_recording():
|
|
28
|
+
return
|
|
29
|
+
|
|
30
|
+
for key in self._allowlist:
|
|
31
|
+
value = baggage.get_baggage(key, parent_context)
|
|
32
|
+
if value is not None:
|
|
33
|
+
span.set_attribute(key, str(value))
|
|
34
|
+
|
|
35
|
+
def on_end(self, span: ReadableSpan) -> None: # noqa: ARG002
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
def shutdown(self) -> None:
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
def force_flush(self, timeout_millis: int = 30000) -> bool: # noqa: ARG002
|
|
42
|
+
return True
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""HTTP client auto-instrumentation for the iii Python SDK.
|
|
2
|
+
|
|
3
|
+
Mirrors the Rust execute_traced_request shape: wraps an httpx Request in an
|
|
4
|
+
OTel CLIENT span with HTTP semantic-convention attributes, injects W3C
|
|
5
|
+
traceparent into outgoing headers, and records exceptions on network errors.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
from opentelemetry import trace
|
|
12
|
+
from opentelemetry.propagate import inject
|
|
13
|
+
from opentelemetry.trace import SpanKind, Status, StatusCode
|
|
14
|
+
|
|
15
|
+
_SAFE_REQUEST_HEADERS = ("content-type", "accept")
|
|
16
|
+
_SAFE_RESPONSE_HEADERS = ("content-type",)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _span_name(method: str, path: str | None) -> str:
|
|
20
|
+
return f"{method} {path}" if path else method
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
async def execute_traced_request(
|
|
24
|
+
client: httpx.AsyncClient,
|
|
25
|
+
request: httpx.Request,
|
|
26
|
+
) -> httpx.Response:
|
|
27
|
+
"""Execute an httpx Request inside an OTel CLIENT span.
|
|
28
|
+
|
|
29
|
+
- Injects W3C traceparent into outgoing request headers.
|
|
30
|
+
- Records HTTP semantic-convention attributes on the span.
|
|
31
|
+
- Sets ERROR span status for responses with status >= 400.
|
|
32
|
+
- Records exceptions for network-level errors.
|
|
33
|
+
"""
|
|
34
|
+
url = request.url
|
|
35
|
+
method = request.method.upper()
|
|
36
|
+
path = url.path or None
|
|
37
|
+
query = url.query
|
|
38
|
+
query_str: str | None
|
|
39
|
+
if isinstance(query, bytes):
|
|
40
|
+
query_str = query.decode() if query else None
|
|
41
|
+
else:
|
|
42
|
+
query_str = query or None
|
|
43
|
+
|
|
44
|
+
attributes: dict[str, str | int] = {
|
|
45
|
+
"http.request.method": method,
|
|
46
|
+
"url.full": str(url),
|
|
47
|
+
}
|
|
48
|
+
if url.host:
|
|
49
|
+
attributes["server.address"] = url.host
|
|
50
|
+
if url.scheme:
|
|
51
|
+
attributes["url.scheme"] = url.scheme
|
|
52
|
+
attributes["network.protocol.name"] = "http"
|
|
53
|
+
if path:
|
|
54
|
+
attributes["url.path"] = path
|
|
55
|
+
if url.port:
|
|
56
|
+
attributes["server.port"] = url.port
|
|
57
|
+
if query_str:
|
|
58
|
+
attributes["url.query"] = query_str
|
|
59
|
+
|
|
60
|
+
tracer = trace.get_tracer("iii-python-sdk")
|
|
61
|
+
name = _span_name(method, path)
|
|
62
|
+
|
|
63
|
+
with tracer.start_as_current_span(name, kind=SpanKind.CLIENT, attributes=attributes) as span:
|
|
64
|
+
carrier: dict[str, str] = {}
|
|
65
|
+
inject(carrier)
|
|
66
|
+
for k, v in carrier.items():
|
|
67
|
+
request.headers[k] = v
|
|
68
|
+
|
|
69
|
+
for h in _SAFE_REQUEST_HEADERS:
|
|
70
|
+
v = request.headers.get(h)
|
|
71
|
+
if v:
|
|
72
|
+
span.set_attribute(f"http.request.header.{h}", v)
|
|
73
|
+
if request.content:
|
|
74
|
+
span.set_attribute("http.request.body.size", len(request.content))
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
response = await client.send(request)
|
|
78
|
+
except httpx.HTTPError as err:
|
|
79
|
+
span.record_exception(err)
|
|
80
|
+
span.set_status(Status(StatusCode.ERROR, str(err)))
|
|
81
|
+
span.set_attribute("error.type", type(err).__name__)
|
|
82
|
+
raise
|
|
83
|
+
|
|
84
|
+
span.set_attribute("http.response.status_code", response.status_code)
|
|
85
|
+
cl = response.headers.get("content-length")
|
|
86
|
+
if cl:
|
|
87
|
+
try:
|
|
88
|
+
span.set_attribute("http.response.body.size", int(cl))
|
|
89
|
+
except ValueError:
|
|
90
|
+
pass
|
|
91
|
+
for h in _SAFE_RESPONSE_HEADERS:
|
|
92
|
+
v = response.headers.get(h)
|
|
93
|
+
if v:
|
|
94
|
+
span.set_attribute(f"http.response.header.{h}", v)
|
|
95
|
+
|
|
96
|
+
if response.status_code >= 400:
|
|
97
|
+
span.set_status(Status(StatusCode.ERROR, str(response.status_code)))
|
|
98
|
+
span.set_attribute("error.type", str(response.status_code))
|
|
99
|
+
else:
|
|
100
|
+
span.set_status(Status(StatusCode.OK))
|
|
101
|
+
|
|
102
|
+
return response
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Logger implementation for the III SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
log = logging.getLogger("iii.logger")
|
|
10
|
+
|
|
11
|
+
_SEVERITY_MAP = {
|
|
12
|
+
"info": ("INFO", 9), # SeverityNumber.INFO
|
|
13
|
+
"warn": ("WARN", 13), # SeverityNumber.WARN
|
|
14
|
+
"error": ("ERROR", 17), # SeverityNumber.ERROR
|
|
15
|
+
"debug": ("DEBUG", 5), # SeverityNumber.DEBUG
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _is_initialized() -> bool:
|
|
20
|
+
"""Internal: True if OTel has been initialized. Imported lazily to avoid a circular import."""
|
|
21
|
+
from .telemetry import _is_initialized as _check
|
|
22
|
+
|
|
23
|
+
return _check()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Logger:
|
|
27
|
+
"""Structured logger that emits logs as OpenTelemetry LogRecords.
|
|
28
|
+
|
|
29
|
+
Every log call automatically captures the active trace and span context,
|
|
30
|
+
correlating your logs with distributed traces without any manual wiring.
|
|
31
|
+
When OTel is not initialized, Logger gracefully falls back to Python
|
|
32
|
+
``logging``.
|
|
33
|
+
|
|
34
|
+
Pass structured data as the second argument to any log method. Using a
|
|
35
|
+
dict of key-value pairs (instead of string interpolation) lets you
|
|
36
|
+
filter, aggregate, and build dashboards in your observability backend.
|
|
37
|
+
|
|
38
|
+
Examples:
|
|
39
|
+
>>> from iii import Logger
|
|
40
|
+
>>> logger = Logger()
|
|
41
|
+
>>>
|
|
42
|
+
>>> # Basic logging — trace context is injected automatically
|
|
43
|
+
>>> logger.info('Worker connected')
|
|
44
|
+
>>>
|
|
45
|
+
>>> # Structured context for dashboards and alerting
|
|
46
|
+
>>> logger.info('Order processed', {'order_id': 'ord_123', 'amount': 49.99, 'currency': 'USD'})
|
|
47
|
+
>>> logger.warn('Retry attempt', {'attempt': 3, 'max_retries': 5, 'endpoint': '/api/charge'})
|
|
48
|
+
>>> logger.error('Payment failed', {'order_id': 'ord_123', 'gateway': 'stripe', 'error_code': 'card_declined'})
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
trace_id: str | None = None,
|
|
54
|
+
service_name: str | None = None,
|
|
55
|
+
span_id: str | None = None,
|
|
56
|
+
) -> None:
|
|
57
|
+
self._trace_id = trace_id
|
|
58
|
+
self._service_name = service_name or ""
|
|
59
|
+
self._span_id = span_id
|
|
60
|
+
|
|
61
|
+
def _emit_otel(self, level: str, message: str, data: Any = None) -> bool:
|
|
62
|
+
"""Emit an OTel LogRecord. Returns True if emitted, False if OTel not active."""
|
|
63
|
+
if not _is_initialized():
|
|
64
|
+
return False
|
|
65
|
+
try:
|
|
66
|
+
from opentelemetry import _logs, trace
|
|
67
|
+
from opentelemetry._logs import LogRecord, SeverityNumber
|
|
68
|
+
|
|
69
|
+
severity_text, severity_num = _SEVERITY_MAP[level]
|
|
70
|
+
otel_logger = _logs.get_logger("iii.logger")
|
|
71
|
+
attrs: dict[str, Any] = {"service.name": self._service_name}
|
|
72
|
+
if data is not None:
|
|
73
|
+
attrs["log.data"] = data
|
|
74
|
+
|
|
75
|
+
span_ctx = trace.get_current_span().get_span_context()
|
|
76
|
+
|
|
77
|
+
if self._trace_id is not None:
|
|
78
|
+
trace_id = int(self._trace_id, 16)
|
|
79
|
+
elif span_ctx.is_valid:
|
|
80
|
+
trace_id = span_ctx.trace_id
|
|
81
|
+
else:
|
|
82
|
+
trace_id = 0
|
|
83
|
+
|
|
84
|
+
if self._span_id is not None:
|
|
85
|
+
span_id = int(self._span_id, 16)
|
|
86
|
+
elif span_ctx.is_valid:
|
|
87
|
+
span_id = span_ctx.span_id
|
|
88
|
+
else:
|
|
89
|
+
span_id = 0
|
|
90
|
+
|
|
91
|
+
trace_flags = span_ctx.trace_flags if span_ctx.is_valid else trace.TraceFlags(0)
|
|
92
|
+
|
|
93
|
+
record = LogRecord(
|
|
94
|
+
timestamp=time.time_ns(),
|
|
95
|
+
observed_timestamp=time.time_ns(),
|
|
96
|
+
severity_text=severity_text,
|
|
97
|
+
severity_number=SeverityNumber(severity_num),
|
|
98
|
+
body=message,
|
|
99
|
+
attributes=attrs,
|
|
100
|
+
trace_id=trace_id,
|
|
101
|
+
span_id=span_id,
|
|
102
|
+
trace_flags=trace_flags,
|
|
103
|
+
)
|
|
104
|
+
otel_logger.emit(record)
|
|
105
|
+
return True
|
|
106
|
+
except Exception:
|
|
107
|
+
return False
|
|
108
|
+
|
|
109
|
+
def _emit(self, level: str, message: str, data: Any = None) -> None:
|
|
110
|
+
"""Emit a log message via OTel, or Python logging as fallback."""
|
|
111
|
+
if self._emit_otel(level, message, data):
|
|
112
|
+
return
|
|
113
|
+
_LOG_METHODS = {
|
|
114
|
+
"info": log.info,
|
|
115
|
+
"warn": log.warning,
|
|
116
|
+
"error": log.error,
|
|
117
|
+
"debug": log.debug,
|
|
118
|
+
}
|
|
119
|
+
log_fn = _LOG_METHODS.get(level, log.info)
|
|
120
|
+
log_fn("[%s] %s", self._service_name, message, extra={"data": data})
|
|
121
|
+
|
|
122
|
+
def info(self, message: str, data: Any = None) -> None:
|
|
123
|
+
"""Log an info-level message.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
message: Human-readable log message.
|
|
127
|
+
data: Structured context attached as OTel log attributes.
|
|
128
|
+
Use dicts of key-value pairs to enable filtering and
|
|
129
|
+
aggregation in your observability backend (e.g. Grafana,
|
|
130
|
+
Datadog, New Relic).
|
|
131
|
+
|
|
132
|
+
Examples:
|
|
133
|
+
>>> logger.info('Order processed', {'order_id': 'ord_123', 'status': 'completed'})
|
|
134
|
+
"""
|
|
135
|
+
self._emit("info", message, data)
|
|
136
|
+
|
|
137
|
+
def warn(self, message: str, data: Any = None) -> None:
|
|
138
|
+
"""Log a warning-level message.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
message: Human-readable log message.
|
|
142
|
+
data: Structured context attached as OTel log attributes.
|
|
143
|
+
Use dicts of key-value pairs to enable filtering and
|
|
144
|
+
aggregation in your observability backend (e.g. Grafana,
|
|
145
|
+
Datadog, New Relic).
|
|
146
|
+
|
|
147
|
+
Examples:
|
|
148
|
+
>>> logger.warn('Retry attempt', {'attempt': 3, 'max_retries': 5, 'endpoint': '/api/charge'})
|
|
149
|
+
"""
|
|
150
|
+
self._emit("warn", message, data)
|
|
151
|
+
|
|
152
|
+
def error(self, message: str, data: Any = None) -> None:
|
|
153
|
+
"""Log an error-level message.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
message: Human-readable log message.
|
|
157
|
+
data: Structured context attached as OTel log attributes.
|
|
158
|
+
Use dicts of key-value pairs to enable filtering and
|
|
159
|
+
aggregation in your observability backend (e.g. Grafana,
|
|
160
|
+
Datadog, New Relic).
|
|
161
|
+
|
|
162
|
+
Examples:
|
|
163
|
+
>>> logger.error('Payment failed', {
|
|
164
|
+
... 'order_id': 'ord_123',
|
|
165
|
+
... 'gateway': 'stripe',
|
|
166
|
+
... 'error_code': 'card_declined',
|
|
167
|
+
... })
|
|
168
|
+
"""
|
|
169
|
+
self._emit("error", message, data)
|
|
170
|
+
|
|
171
|
+
def debug(self, message: str, data: Any = None) -> None:
|
|
172
|
+
"""Log a debug-level message.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
message: Human-readable log message.
|
|
176
|
+
data: Structured context attached as OTel log attributes.
|
|
177
|
+
Use dicts of key-value pairs to enable filtering and
|
|
178
|
+
aggregation in your observability backend (e.g. Grafana,
|
|
179
|
+
Datadog, New Relic).
|
|
180
|
+
|
|
181
|
+
Examples:
|
|
182
|
+
>>> logger.debug('Cache lookup', {'key': 'user:42', 'hit': False})
|
|
183
|
+
"""
|
|
184
|
+
self._emit("debug", message, data)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Payload redaction + truncation for invocation event capture."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from typing import Any, Optional
|
|
8
|
+
|
|
9
|
+
REDACTED_PLACEHOLDER = "[REDACTED]"
|
|
10
|
+
_TRUNCATION_MARKER = '..."[TRUNCATED]"'
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def resolve_max_bytes_from_env() -> Optional[int]:
|
|
14
|
+
raw = os.environ.get("III_TRACE_PAYLOAD_MAX_BYTES")
|
|
15
|
+
if raw is None:
|
|
16
|
+
return None
|
|
17
|
+
trimmed = raw.strip()
|
|
18
|
+
if not trimmed or trimmed.lower() == "unlimited":
|
|
19
|
+
return None
|
|
20
|
+
try:
|
|
21
|
+
parsed = int(trimmed)
|
|
22
|
+
except ValueError:
|
|
23
|
+
return None
|
|
24
|
+
if parsed <= 0:
|
|
25
|
+
return None
|
|
26
|
+
return parsed
|
|
27
|
+
|
|
28
|
+
_SENSITIVE_FRAGMENTS = (
|
|
29
|
+
"api_key",
|
|
30
|
+
"apikey",
|
|
31
|
+
"api-key",
|
|
32
|
+
"password",
|
|
33
|
+
"secret",
|
|
34
|
+
"credential",
|
|
35
|
+
"authorization",
|
|
36
|
+
"auth_token",
|
|
37
|
+
"access_token",
|
|
38
|
+
"refresh_token",
|
|
39
|
+
"bearer",
|
|
40
|
+
"private_key",
|
|
41
|
+
"client_secret",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _is_sensitive_key(key: str) -> bool:
|
|
46
|
+
lower = key.lower()
|
|
47
|
+
if any(fragment in lower for fragment in _SENSITIVE_FRAGMENTS):
|
|
48
|
+
return True
|
|
49
|
+
# ``token`` alone is too common a substring; require whole-key or suffix match.
|
|
50
|
+
return lower == "token" or lower.endswith("_token") or lower.endswith("-token")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def redact(value: Any) -> Any:
|
|
54
|
+
if isinstance(value, dict):
|
|
55
|
+
return {
|
|
56
|
+
k: REDACTED_PLACEHOLDER if _is_sensitive_key(k) else redact(v)
|
|
57
|
+
for k, v in value.items()
|
|
58
|
+
}
|
|
59
|
+
if isinstance(value, list):
|
|
60
|
+
return [redact(item) for item in value]
|
|
61
|
+
if isinstance(value, tuple):
|
|
62
|
+
return tuple(redact(item) for item in value)
|
|
63
|
+
return value
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def redact_and_truncate(
|
|
67
|
+
value: Any, max_bytes: Optional[int] = None
|
|
68
|
+
) -> tuple[str, bool]:
|
|
69
|
+
redacted = redact(value)
|
|
70
|
+
try:
|
|
71
|
+
serialized = json.dumps(redacted, default=str, ensure_ascii=False)
|
|
72
|
+
except (TypeError, ValueError):
|
|
73
|
+
serialized = "null"
|
|
74
|
+
|
|
75
|
+
if max_bytes is None or max_bytes <= 0:
|
|
76
|
+
return serialized, False
|
|
77
|
+
|
|
78
|
+
encoded = serialized.encode("utf-8")
|
|
79
|
+
if len(encoded) <= max_bytes:
|
|
80
|
+
return serialized, False
|
|
81
|
+
|
|
82
|
+
marker_len = len(_TRUNCATION_MARKER.encode("utf-8"))
|
|
83
|
+
if max_bytes <= marker_len:
|
|
84
|
+
return _TRUNCATION_MARKER[:max_bytes], True
|
|
85
|
+
|
|
86
|
+
cap = max_bytes - marker_len
|
|
87
|
+
# Walk back to a UTF-8 boundary so we don't emit half-codepoints.
|
|
88
|
+
cut = cap
|
|
89
|
+
while cut > 0 and (encoded[cut] & 0xC0) == 0x80:
|
|
90
|
+
cut -= 1
|
|
91
|
+
truncated = encoded[:cut].decode("utf-8", errors="ignore") + _TRUNCATION_MARKER
|
|
92
|
+
return truncated, True
|
|
File without changes
|