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 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
+ )