semlog 0.2.0__py3-none-any.whl
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.
- semlog/__init__.py +45 -0
- semlog/__main__.py +21 -0
- semlog/_baggage.py +65 -0
- semlog/_config.py +192 -0
- semlog/_context.py +157 -0
- semlog/_format.py +392 -0
- semlog/_identity.py +145 -0
- semlog/_middleware.py +214 -0
- semlog/_modes.py +176 -0
- semlog/_request_id.py +22 -0
- semlog/_trace.py +42 -0
- semlog/_transport.py +352 -0
- semlog/_uuid7_fallback.py +44 -0
- semlog/agent_guide.md +363 -0
- semlog-0.2.0.dist-info/METADATA +337 -0
- semlog-0.2.0.dist-info/RECORD +18 -0
- semlog-0.2.0.dist-info/WHEEL +4 -0
- semlog-0.2.0.dist-info/licenses/LICENSE +202 -0
semlog/__init__.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""SEMLOG: a stdlib-only structured logging library (CP-015, 8 public
|
|
2
|
+
names once every phase lands); ``__all__`` never lists a name before it
|
|
3
|
+
exists."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from ._config import configure
|
|
8
|
+
from ._context import bind, inject, operation
|
|
9
|
+
from ._identity import _dist_version
|
|
10
|
+
from ._middleware import ASGIMiddleware, WSGIMiddleware
|
|
11
|
+
from ._transport import flush
|
|
12
|
+
|
|
13
|
+
# The only code copy of the semconv version pinned in STANDARDS.md §4 (design
|
|
14
|
+
# #170 §17.3); `llm()`'s generated header cites it alongside the installed
|
|
15
|
+
# semlog version, so the stored guide itself carries no version literal.
|
|
16
|
+
_OTEL_SEMCONV_VERSION = "1.44.0"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def llm() -> str:
|
|
20
|
+
"""Return the version-matched agent guide from package data, preceded
|
|
21
|
+
by a header generated at read time; no cache (DOC-011, CP-018)."""
|
|
22
|
+
import importlib.resources
|
|
23
|
+
|
|
24
|
+
guide = (
|
|
25
|
+
importlib.resources.files(__package__)
|
|
26
|
+
.joinpath("agent_guide.md")
|
|
27
|
+
.read_text(encoding="utf-8")
|
|
28
|
+
)
|
|
29
|
+
header = (
|
|
30
|
+
f"---\nsemlog_version: {_dist_version('semlog')}\n"
|
|
31
|
+
f"otel_semconv_version: {_OTEL_SEMCONV_VERSION}\n---\n\n"
|
|
32
|
+
)
|
|
33
|
+
return header + guide
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
__all__: tuple[str, ...] = ( # noqa: RUF022
|
|
37
|
+
"configure",
|
|
38
|
+
"WSGIMiddleware",
|
|
39
|
+
"ASGIMiddleware",
|
|
40
|
+
"operation",
|
|
41
|
+
"bind",
|
|
42
|
+
"inject",
|
|
43
|
+
"flush",
|
|
44
|
+
"llm",
|
|
45
|
+
)
|
semlog/__main__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""`python -m semlog` entry point: only the `llm` subcommand, no
|
|
2
|
+
console-script (design-decisions #170 §17.4; CP-018)."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from . import llm
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main(argv: list[str]) -> int:
|
|
12
|
+
"""Return the process exit code for `argv` (`sys.argv[1:]`-shaped)."""
|
|
13
|
+
if argv == ["llm"]:
|
|
14
|
+
sys.stdout.buffer.write(llm().encode("utf-8"))
|
|
15
|
+
return 0
|
|
16
|
+
sys.stderr.write("usage: python -m semlog llm\n")
|
|
17
|
+
return 2
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
if __name__ == "__main__":
|
|
21
|
+
sys.exit(main(sys.argv[1:]))
|
semlog/_baggage.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""W3C Baggage parsing and allowlist codec (STANDARDS §8, TCP-007).
|
|
2
|
+
|
|
3
|
+
Splits an inbound baggage header into raw, percent-encoded members in
|
|
4
|
+
header order, skipping malformed members and truncating (never rejecting
|
|
5
|
+
outright) past the spec limits of 64 members or 8192 bytes.
|
|
6
|
+
`to_log_attributes` copies only the allowlisted keys into flat,
|
|
7
|
+
percent-decoded, UNPREFIXED attributes: the formatter applies the
|
|
8
|
+
configured baggage prefix once, at format time (design #162 §2.1 step 3c;
|
|
9
|
+
engram #239 follow-up) -- prefixing here too would double it once
|
|
10
|
+
`operation()`/`bind()` (Phase 3) wire this straight into `Snapshot.baggage`
|
|
11
|
+
(`baggage.baggage.x`). Outbound stripping at a trust boundary (TCP-008) is
|
|
12
|
+
a later task (`inject()`, 3.11/3.12).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
import urllib.parse
|
|
19
|
+
from types import SimpleNamespace
|
|
20
|
+
|
|
21
|
+
_MAX_MEMBERS = 64
|
|
22
|
+
_MAX_BYTES = 8192
|
|
23
|
+
_TOKEN_RE = re.compile(r"^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$")
|
|
24
|
+
|
|
25
|
+
# Process-wide defaults set by `configure(baggage_allow=..., accept_inbound_
|
|
26
|
+
# baggage=...)` (design #162/#170 §3.3). `_context.snapshot_from_headers`
|
|
27
|
+
# reads this directly with no import cycle: `_config` imports `_format`,
|
|
28
|
+
# which imports `_context`, so `_context` cannot import `_config` back --
|
|
29
|
+
# this module sits below both, so both can depend on it safely.
|
|
30
|
+
defaults = SimpleNamespace(accept_inbound_baggage=True, baggage_allow=())
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def parse_baggage(header: str | None) -> tuple[tuple[str, str], ...]:
|
|
34
|
+
"""Parse an inbound baggage header into ``(key, value)`` members."""
|
|
35
|
+
if not header:
|
|
36
|
+
return ()
|
|
37
|
+
members: list[tuple[str, str]] = []
|
|
38
|
+
total_bytes = 0
|
|
39
|
+
for raw_member in header.split(","):
|
|
40
|
+
if len(members) >= _MAX_MEMBERS:
|
|
41
|
+
break
|
|
42
|
+
candidate = raw_member.strip()
|
|
43
|
+
if not candidate:
|
|
44
|
+
continue
|
|
45
|
+
member_bytes = len(candidate.encode("utf-8"))
|
|
46
|
+
if total_bytes + member_bytes > _MAX_BYTES:
|
|
47
|
+
break
|
|
48
|
+
key, separator, value = candidate.split(";", 1)[0].partition("=")
|
|
49
|
+
key = key.strip()
|
|
50
|
+
if not separator or not _TOKEN_RE.match(key):
|
|
51
|
+
continue
|
|
52
|
+
members.append((key, value.strip()))
|
|
53
|
+
total_bytes += member_bytes
|
|
54
|
+
return tuple(members)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def to_log_attributes(
|
|
58
|
+
members: tuple[tuple[str, str], ...], allow: tuple[str, ...]
|
|
59
|
+
) -> dict[str, str]:
|
|
60
|
+
"""Copy allowlisted members into flat, percent-decoded, unprefixed log
|
|
61
|
+
attributes (TCP-007); the formatter applies the baggage prefix."""
|
|
62
|
+
allowed = set(allow)
|
|
63
|
+
return {
|
|
64
|
+
key: urllib.parse.unquote(value) for key, value in members if key in allowed
|
|
65
|
+
}
|
semlog/_config.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""`configure()`, the library's single entry point (design #162 §3.3/3.4).
|
|
2
|
+
No configuration object (engram #238; CP-015): state lives in one
|
|
3
|
+
module-level `types.SimpleNamespace`, not a class -- a stdlib type never
|
|
4
|
+
counts against the class budget. `current()` returns it directly."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
from types import SimpleNamespace
|
|
11
|
+
|
|
12
|
+
from . import _modes
|
|
13
|
+
from ._baggage import defaults as _baggage_defaults
|
|
14
|
+
from ._format import Formatter
|
|
15
|
+
from ._identity import resolve_identity
|
|
16
|
+
from ._transport import attach_root as _attach_root
|
|
17
|
+
from ._transport import capture_loggers as _capture_loggers
|
|
18
|
+
from ._transport import install as _install_pipeline
|
|
19
|
+
|
|
20
|
+
_CATALOG_MODES = ("off", "warn", "strict")
|
|
21
|
+
_OVERFLOW_MODES = ("block", "drop")
|
|
22
|
+
# LP-010: stdlib leaves the root logger at WARNING when unset; this
|
|
23
|
+
# library always sets an explicit, configurable level instead (default
|
|
24
|
+
# INFO), so INFO records are never silently dropped by omission.
|
|
25
|
+
_LEVELS = {"DEBUG": 10, "INFO": 20, "WARNING": 30, "ERROR": 40, "CRITICAL": 50}
|
|
26
|
+
|
|
27
|
+
_state = SimpleNamespace(
|
|
28
|
+
identity=None,
|
|
29
|
+
namespace="app",
|
|
30
|
+
catalog=None,
|
|
31
|
+
catalog_mode=None,
|
|
32
|
+
max_attributes=128,
|
|
33
|
+
max_attribute_length=None,
|
|
34
|
+
queue=True,
|
|
35
|
+
queue_size=10000,
|
|
36
|
+
overflow="block",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def current() -> SimpleNamespace:
|
|
41
|
+
"""Return the state of the last `configure()` call (test/internal use)."""
|
|
42
|
+
if _state.identity is None:
|
|
43
|
+
raise RuntimeError("configure() has not been called yet")
|
|
44
|
+
return _state
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _resolve_limit(explicit: int | None, kind: str, default: int | None) -> int | None:
|
|
48
|
+
"""LRC-010's precedence: parameter > log-record `OTEL_*` var > generic var > default."""
|
|
49
|
+
if explicit is not None:
|
|
50
|
+
return explicit
|
|
51
|
+
for env_name in (
|
|
52
|
+
f"OTEL_LOGRECORD_ATTRIBUTE_{kind}_LIMIT",
|
|
53
|
+
f"OTEL_ATTRIBUTE_{kind}_LIMIT",
|
|
54
|
+
):
|
|
55
|
+
raw = os.environ.get(env_name)
|
|
56
|
+
if raw:
|
|
57
|
+
try:
|
|
58
|
+
return int(raw)
|
|
59
|
+
except ValueError:
|
|
60
|
+
continue
|
|
61
|
+
return default
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def configure(
|
|
65
|
+
*,
|
|
66
|
+
service_name: str | None = None,
|
|
67
|
+
service_version: str | None = None,
|
|
68
|
+
service_namespace: str | None = None,
|
|
69
|
+
service_instance_id: str | None = None,
|
|
70
|
+
environment: str | None = None,
|
|
71
|
+
identity: tuple[object, ...] | None = None,
|
|
72
|
+
identity_levels: tuple[str, ...] = ("role", "component"),
|
|
73
|
+
level: str = "INFO",
|
|
74
|
+
namespace: str = "app",
|
|
75
|
+
capture_loggers: tuple[str, ...] = (),
|
|
76
|
+
redact_keys: tuple[str, ...] = (),
|
|
77
|
+
baggage_allow: tuple[str, ...] = (),
|
|
78
|
+
baggage_prefix: str = "baggage.",
|
|
79
|
+
accept_inbound_baggage: bool = True,
|
|
80
|
+
catalog: dict | None = None,
|
|
81
|
+
catalog_mode: str | None = None,
|
|
82
|
+
max_attributes: int | None = None,
|
|
83
|
+
max_attribute_length: int | None = None,
|
|
84
|
+
queue: bool = True,
|
|
85
|
+
queue_size: int = 10000,
|
|
86
|
+
overflow: str = "block",
|
|
87
|
+
mode: str | None = None,
|
|
88
|
+
search_dir: str | None = None,
|
|
89
|
+
) -> None:
|
|
90
|
+
"""Resolve identity and validate configuration; last call wins. `mode`
|
|
91
|
+
(LM-001) resolves before any other work, so an invalid value raises
|
|
92
|
+
before anything else in this process is touched."""
|
|
93
|
+
resolved_mode = _modes.resolve_mode(mode, search_dir=search_dir)
|
|
94
|
+
if level not in _LEVELS:
|
|
95
|
+
raise ValueError(f"level must be one of {sorted(_LEVELS)}, got {level!r}")
|
|
96
|
+
if catalog is not None and not isinstance(catalog, dict):
|
|
97
|
+
raise TypeError(
|
|
98
|
+
f"catalog must be a JSON-shaped dict document, not {type(catalog).__name__}"
|
|
99
|
+
)
|
|
100
|
+
if overflow not in _OVERFLOW_MODES:
|
|
101
|
+
raise ValueError(f"overflow must be one of {_OVERFLOW_MODES}, got {overflow!r}")
|
|
102
|
+
if not (isinstance(queue_size, int) and queue_size > 0):
|
|
103
|
+
raise ValueError(f"queue_size must be a positive int, got {queue_size!r}")
|
|
104
|
+
|
|
105
|
+
default_catalog_mode = "off" if catalog is None else "warn"
|
|
106
|
+
resolved_catalog_mode = (
|
|
107
|
+
default_catalog_mode if catalog_mode is None else catalog_mode
|
|
108
|
+
)
|
|
109
|
+
if resolved_catalog_mode not in _CATALOG_MODES:
|
|
110
|
+
raise ValueError(
|
|
111
|
+
f"catalog_mode must be one of {_CATALOG_MODES}, got "
|
|
112
|
+
f"{resolved_catalog_mode!r}"
|
|
113
|
+
)
|
|
114
|
+
if resolved_mode == "hybrid" and capture_loggers:
|
|
115
|
+
# LM-003: hybrid never takes root, so there is no pipeline handler
|
|
116
|
+
# a captured logger's records could propagate to instead.
|
|
117
|
+
raise ValueError("capture_loggers is not supported in hybrid mode")
|
|
118
|
+
|
|
119
|
+
if resolved_mode == "off":
|
|
120
|
+
# LM-005: as if semlog were not installed -- nothing else runs.
|
|
121
|
+
_modes.state.mode = "off"
|
|
122
|
+
_modes.state.marking = False
|
|
123
|
+
return
|
|
124
|
+
|
|
125
|
+
# Hybrid defers its pyproject diagnostic (SI-005 erratum 8): no
|
|
126
|
+
# pipeline exists yet to carry it as JSON, so it is collected here and
|
|
127
|
+
# emitted after install(), marked `semlog=True`.
|
|
128
|
+
pending_diagnostics = [] if resolved_mode == "hybrid" else None
|
|
129
|
+
_state.identity = resolve_identity(
|
|
130
|
+
service_name=service_name,
|
|
131
|
+
service_version=service_version,
|
|
132
|
+
service_namespace=service_namespace,
|
|
133
|
+
service_instance_id=service_instance_id,
|
|
134
|
+
environment=environment,
|
|
135
|
+
identity=identity,
|
|
136
|
+
identity_levels=identity_levels,
|
|
137
|
+
namespace=namespace,
|
|
138
|
+
search_dir=search_dir,
|
|
139
|
+
diagnostics=pending_diagnostics,
|
|
140
|
+
)
|
|
141
|
+
_state.namespace, _state.catalog = namespace, catalog
|
|
142
|
+
_state.catalog_mode = resolved_catalog_mode
|
|
143
|
+
_state.max_attributes = _resolve_limit(max_attributes, "COUNT", 128)
|
|
144
|
+
_state.max_attribute_length = _resolve_limit(
|
|
145
|
+
max_attribute_length, "VALUE_LENGTH", None
|
|
146
|
+
)
|
|
147
|
+
_state.level = level
|
|
148
|
+
_state.queue, _state.queue_size, _state.overflow = queue, queue_size, overflow
|
|
149
|
+
_baggage_defaults.accept_inbound_baggage = accept_inbound_baggage
|
|
150
|
+
_baggage_defaults.baggage_allow = baggage_allow
|
|
151
|
+
|
|
152
|
+
formatter = Formatter(
|
|
153
|
+
identity=_state.identity,
|
|
154
|
+
namespace=namespace,
|
|
155
|
+
baggage_prefix=baggage_prefix,
|
|
156
|
+
catalog=catalog,
|
|
157
|
+
catalog_mode=resolved_catalog_mode,
|
|
158
|
+
max_attributes=_state.max_attributes,
|
|
159
|
+
max_attribute_length=_state.max_attribute_length,
|
|
160
|
+
redact_keys=redact_keys,
|
|
161
|
+
)
|
|
162
|
+
handler = _install_pipeline(
|
|
163
|
+
formatter,
|
|
164
|
+
namespace=namespace,
|
|
165
|
+
queue=queue,
|
|
166
|
+
queue_size=queue_size,
|
|
167
|
+
overflow=overflow,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
if resolved_mode == "full":
|
|
171
|
+
# LP-010: full always sets an explicit root level; hybrid leaves
|
|
172
|
+
# root's handlers and level exactly as found.
|
|
173
|
+
_attach_root(handler, level=_LEVELS[level], replace_all=True)
|
|
174
|
+
_capture_loggers(capture_loggers)
|
|
175
|
+
else:
|
|
176
|
+
# Hybrid never takes root: only detach a handler THIS library left
|
|
177
|
+
# there from an earlier full configure(), never a foreign one.
|
|
178
|
+
root = logging.getLogger()
|
|
179
|
+
for existing in list(root.handlers):
|
|
180
|
+
if getattr(existing, "_semlog_root", False):
|
|
181
|
+
root.removeHandler(existing)
|
|
182
|
+
_modes.arm_routing()
|
|
183
|
+
|
|
184
|
+
# `marking`/`mode` are the very last things this call touches: a second
|
|
185
|
+
# call that raises during validation above must never reach this point,
|
|
186
|
+
# so it leaves both exactly as the last successful call left them
|
|
187
|
+
# (validation-4 minor).
|
|
188
|
+
_modes.state.mode = resolved_mode
|
|
189
|
+
_modes.state.marking = resolved_mode == "hybrid"
|
|
190
|
+
|
|
191
|
+
if pending_diagnostics:
|
|
192
|
+
logging.getLogger("semlog").info(pending_diagnostics[0], semlog=True)
|
semlog/_context.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""One immutable context snapshot per ``ContextVar`` (TCP-005), shared by
|
|
2
|
+
``operation()``, ``bind()`` and the middlewares."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import contextlib
|
|
7
|
+
import contextvars
|
|
8
|
+
import dataclasses
|
|
9
|
+
import logging
|
|
10
|
+
import urllib.parse
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
from types import MappingProxyType
|
|
13
|
+
|
|
14
|
+
from . import _modes
|
|
15
|
+
from ._baggage import defaults as _baggage_defaults
|
|
16
|
+
from ._baggage import parse_baggage, to_log_attributes
|
|
17
|
+
from ._request_id import generate_request_id
|
|
18
|
+
from ._trace import new_span, parse_traceparent
|
|
19
|
+
|
|
20
|
+
# Shared empty default. Python 3.11's dataclasses reject an unhashable default
|
|
21
|
+
# (mappingproxy only became hashable in 3.12), so it is supplied by a factory.
|
|
22
|
+
_EMPTY: Mapping[str, object] = MappingProxyType({})
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclasses.dataclass(frozen=True)
|
|
26
|
+
class Snapshot:
|
|
27
|
+
"""An immutable, point-in-time view of the bound operation context."""
|
|
28
|
+
|
|
29
|
+
trace_id: str | None = None
|
|
30
|
+
span_id: str | None = None
|
|
31
|
+
trace_flags: str | None = None
|
|
32
|
+
tracestate: str | None = None
|
|
33
|
+
request_id: str | None = None
|
|
34
|
+
baggage: Mapping[str, str] = dataclasses.field(default_factory=lambda: _EMPTY)
|
|
35
|
+
attributes: Mapping[str, object] = dataclasses.field(default_factory=lambda: _EMPTY)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
_current: contextvars.ContextVar[Snapshot | None] = contextvars.ContextVar(
|
|
39
|
+
"semlog_context", default=None
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# `current`/`push`/`pop`: the ContextVar's own bound get/set/reset need no
|
|
43
|
+
# wrapper -- `current()` reads the bound snapshot (or `None`); `push()`
|
|
44
|
+
# binds one and returns a reset token; `pop()` resets to the prior value.
|
|
45
|
+
current, push, pop = _current.get, _current.set, _current.reset
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def snapshot_from_headers(get_header, *, baggage_allow=None) -> Snapshot:
|
|
49
|
+
"""Build an inbound `Snapshot` from a header getter (design #162 §2.2/§2.3):
|
|
50
|
+
the shared primitive both middlewares and `operation()` bind from. The
|
|
51
|
+
formatter applies the configured baggage prefix at format time, not here.
|
|
52
|
+
`baggage_allow=None` (the default) falls back to `configure(baggage_
|
|
53
|
+
allow=...)`'s process-wide default; `configure(accept_inbound_baggage=
|
|
54
|
+
False)` ignores the inbound header entirely, regardless of any allowlist."""
|
|
55
|
+
inbound = parse_traceparent(get_header("traceparent"))
|
|
56
|
+
tracestate = get_header("tracestate") if inbound is not None else None
|
|
57
|
+
(trace_id, span_id, trace_flags), tracestate = new_span(inbound, tracestate)
|
|
58
|
+
if _baggage_defaults.accept_inbound_baggage:
|
|
59
|
+
allow = (
|
|
60
|
+
baggage_allow
|
|
61
|
+
if baggage_allow is not None
|
|
62
|
+
else _baggage_defaults.baggage_allow
|
|
63
|
+
)
|
|
64
|
+
baggage = to_log_attributes(parse_baggage(get_header("baggage")), allow)
|
|
65
|
+
else:
|
|
66
|
+
baggage = {}
|
|
67
|
+
return Snapshot(
|
|
68
|
+
trace_id=trace_id,
|
|
69
|
+
span_id=span_id,
|
|
70
|
+
trace_flags=trace_flags,
|
|
71
|
+
tracestate=tracestate,
|
|
72
|
+
request_id=generate_request_id(),
|
|
73
|
+
baggage=baggage,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@contextlib.contextmanager
|
|
78
|
+
def operation(headers=None, *, baggage_allow=None):
|
|
79
|
+
"""Bind a trace/baggage scope for non-HTTP work (design #162 §2.3,
|
|
80
|
+
TCP-005): the same primitive the HTTP middlewares use. With `headers`,
|
|
81
|
+
parses them like an inbound request; without, starts a child of the
|
|
82
|
+
current operation if one is active, else a fresh trace. In `off` mode
|
|
83
|
+
(LM-005), this stays a working context manager but never actually
|
|
84
|
+
binds anything: it yields a fresh, unbound `Snapshot()` and every
|
|
85
|
+
field-generation cost (trace/span ids, baggage parsing) is skipped."""
|
|
86
|
+
if _modes.is_off():
|
|
87
|
+
yield Snapshot()
|
|
88
|
+
return
|
|
89
|
+
parent = current()
|
|
90
|
+
if headers is not None:
|
|
91
|
+
snapshot = snapshot_from_headers(headers.get, baggage_allow=baggage_allow)
|
|
92
|
+
elif parent is not None and parent.trace_id is not None:
|
|
93
|
+
inbound = (parent.trace_id, parent.span_id, parent.trace_flags)
|
|
94
|
+
(trace_id, span_id, trace_flags), tracestate = new_span(
|
|
95
|
+
inbound, parent.tracestate
|
|
96
|
+
)
|
|
97
|
+
snapshot = dataclasses.replace(
|
|
98
|
+
parent,
|
|
99
|
+
trace_id=trace_id,
|
|
100
|
+
span_id=span_id,
|
|
101
|
+
trace_flags=trace_flags,
|
|
102
|
+
tracestate=tracestate,
|
|
103
|
+
request_id=generate_request_id(),
|
|
104
|
+
)
|
|
105
|
+
else:
|
|
106
|
+
snapshot = snapshot_from_headers(
|
|
107
|
+
lambda _name: None, baggage_allow=baggage_allow
|
|
108
|
+
)
|
|
109
|
+
token = push(snapshot)
|
|
110
|
+
try:
|
|
111
|
+
yield snapshot
|
|
112
|
+
finally:
|
|
113
|
+
pop(token)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
_bind_warned = False
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def bind(attributes: Mapping[str, object]) -> None:
|
|
120
|
+
"""Merge `attributes` into the active scope (TCP-005); outside a scope,
|
|
121
|
+
warn once through the `semlog` logger and no-op (TCP-012). In `off`
|
|
122
|
+
mode, that diagnostic is suppressed entirely (LM-005): `off`'s
|
|
123
|
+
`operation()` never binds anything either, so `bind()` already has no
|
|
124
|
+
effect there; only the once-per-process warning needs its own gate."""
|
|
125
|
+
global _bind_warned
|
|
126
|
+
snapshot = current()
|
|
127
|
+
if snapshot is None:
|
|
128
|
+
if not _bind_warned and not _modes.is_off():
|
|
129
|
+
_bind_warned = True
|
|
130
|
+
logging.getLogger("semlog").warning(
|
|
131
|
+
"semlog.log.bind_ignored", stack_info=True, semlog=True
|
|
132
|
+
)
|
|
133
|
+
return
|
|
134
|
+
push(
|
|
135
|
+
dataclasses.replace(snapshot, attributes={**snapshot.attributes, **attributes})
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def inject(headers: dict, *, trusted: bool = True) -> None:
|
|
140
|
+
"""Add outbound `traceparent`/`tracestate`/`baggage` to `headers` in
|
|
141
|
+
place, client-agnostic (TCP-006/008/009/011); a no-op with no bound
|
|
142
|
+
trace or an already-present `traceparent` (any case)."""
|
|
143
|
+
snapshot = current()
|
|
144
|
+
if snapshot is None or snapshot.trace_id is None:
|
|
145
|
+
return
|
|
146
|
+
if any(key.lower() == "traceparent" for key in headers):
|
|
147
|
+
return
|
|
148
|
+
headers["traceparent"] = (
|
|
149
|
+
f"00-{snapshot.trace_id}-{snapshot.span_id}-{snapshot.trace_flags}"
|
|
150
|
+
)
|
|
151
|
+
if snapshot.tracestate is not None:
|
|
152
|
+
headers["tracestate"] = snapshot.tracestate
|
|
153
|
+
if trusted and snapshot.baggage:
|
|
154
|
+
headers["baggage"] = ",".join(
|
|
155
|
+
f"{key}={urllib.parse.quote(str(value))}"
|
|
156
|
+
for key, value in snapshot.baggage.items()
|
|
157
|
+
)
|