log-foundry 0.0.2.dev1__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.
- log_forge/__init__.py +45 -0
- log_forge/api.py +89 -0
- log_forge/config.py +79 -0
- log_forge/console.py +30 -0
- log_forge/context.py +62 -0
- log_forge/decorator.py +157 -0
- log_forge/ids.py +29 -0
- log_forge/model.py +106 -0
- log_forge/py.typed +0 -0
- log_forge/sinks/__init__.py +0 -0
- log_forge/sinks/_chunk.py +58 -0
- log_forge/sinks/_socket.py +105 -0
- log_forge/sinks/_time.py +28 -0
- log_forge/sinks/base.py +22 -0
- log_forge/sinks/callback.py +42 -0
- log_forge/sinks/clickhouse.py +111 -0
- log_forge/sinks/datadog.py +53 -0
- log_forge/sinks/elasticsearch.py +68 -0
- log_forge/sinks/eventhubs.py +110 -0
- log_forge/sinks/file.py +177 -0
- log_forge/sinks/filtering.py +71 -0
- log_forge/sinks/firehose.py +91 -0
- log_forge/sinks/honeycomb.py +38 -0
- log_forge/sinks/http.py +202 -0
- log_forge/sinks/kafka.py +71 -0
- log_forge/sinks/kinesis.py +99 -0
- log_forge/sinks/logging_sink.py +104 -0
- log_forge/sinks/logstash.py +73 -0
- log_forge/sinks/loki.py +48 -0
- log_forge/sinks/mongodb.py +105 -0
- log_forge/sinks/multi.py +53 -0
- log_forge/sinks/nats.py +72 -0
- log_forge/sinks/newrelic.py +28 -0
- log_forge/sinks/postgres.py +101 -0
- log_forge/sinks/pubsub.py +45 -0
- log_forge/sinks/rabbitmq.py +129 -0
- log_forge/sinks/redis.py +90 -0
- log_forge/sinks/sentry.py +136 -0
- log_forge/sinks/sns.py +82 -0
- log_forge/sinks/splunk.py +51 -0
- log_forge/sinks/sqlite.py +100 -0
- log_forge/sinks/sqs.py +107 -0
- log_forge/sinks/stdout.py +31 -0
- log_forge/sinks/syslog.py +88 -0
- log_forge/sinks/transform.py +46 -0
- log_forge/sinks/util.py +68 -0
- log_forge/worker.py +153 -0
- log_foundry-0.0.2.dev1.dist-info/METADATA +537 -0
- log_foundry-0.0.2.dev1.dist-info/RECORD +51 -0
- log_foundry-0.0.2.dev1.dist-info/WHEEL +4 -0
- log_foundry-0.0.2.dev1.dist-info/licenses/LICENSE +21 -0
log_forge/__init__.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""log_forge — consistent, structured logs per decorated function call.
|
|
2
|
+
|
|
3
|
+
Public façade (module-function shape). Exposes configuration, the ``@trace`` decorator, the
|
|
4
|
+
``debug/info/warning/error/critical`` emitters, ``set_baggage``, and ``shutdown`` (graceful
|
|
5
|
+
drain of the background worker).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from importlib.metadata import PackageNotFoundError
|
|
9
|
+
from importlib.metadata import version as _dist_version
|
|
10
|
+
|
|
11
|
+
from log_forge.api import critical, debug, error, info, set_baggage, warning
|
|
12
|
+
from log_forge.config import configure, get_config
|
|
13
|
+
from log_forge.decorator import trace
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
# Distribution name ("log-foundry") differs from the import name ("log_forge").
|
|
17
|
+
__version__ = _dist_version("log-foundry")
|
|
18
|
+
except PackageNotFoundError: # running from a source tree that isn't installed
|
|
19
|
+
__version__ = "0.0.0"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def shutdown() -> None:
|
|
23
|
+
"""Flush buffered events and close the sink, blocking until drained. Idempotent.
|
|
24
|
+
|
|
25
|
+
Also registered via ``atexit``; call it explicitly before a fast process exit when you
|
|
26
|
+
want to be certain the tail of the queue reached the sink (SPEC-004 FR-005).
|
|
27
|
+
"""
|
|
28
|
+
from log_forge.decorator import _shutdown_worker
|
|
29
|
+
|
|
30
|
+
_shutdown_worker()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"configure",
|
|
35
|
+
"get_config",
|
|
36
|
+
"trace",
|
|
37
|
+
"debug",
|
|
38
|
+
"info",
|
|
39
|
+
"warning",
|
|
40
|
+
"error",
|
|
41
|
+
"critical",
|
|
42
|
+
"set_baggage",
|
|
43
|
+
"shutdown",
|
|
44
|
+
"__version__",
|
|
45
|
+
]
|
log_forge/api.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""User-facing log emitters + baggage re-export (SPEC-002, arch §6).
|
|
2
|
+
|
|
3
|
+
The level functions (``debug``/``info``/``warning``/``error``/``critical``) let user code emit
|
|
4
|
+
its own structured events *inside* a decorated call: each appends one event (built via the
|
|
5
|
+
SPEC-001 :func:`~log_forge.model.build_event`, stamped with current baggage) to the current
|
|
6
|
+
span's queue, so the whole call's logs flush together at span end. A level call made with no
|
|
7
|
+
active span is not dropped — it becomes a standalone one-event span with a fresh ``trace_id``,
|
|
8
|
+
flushed directly (FR-004). ``echo=True`` additionally surfaces a human-readable line to the
|
|
9
|
+
console synchronously (FR-002), without waiting for the async flush; the event still reaches
|
|
10
|
+
the sink via the normal path.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from log_forge import context
|
|
16
|
+
from log_forge.config import _ensure_sink
|
|
17
|
+
from log_forge.console import ConsoleWriter
|
|
18
|
+
from log_forge.context import set_baggage
|
|
19
|
+
from log_forge.ids import new_span_id, new_trace_id
|
|
20
|
+
from log_forge.model import Span, build_event
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"debug",
|
|
24
|
+
"info",
|
|
25
|
+
"warning",
|
|
26
|
+
"error",
|
|
27
|
+
"critical",
|
|
28
|
+
"set_baggage",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
# One console writer per process (default stream sys.stderr). Configurable overrides are
|
|
32
|
+
# deferred (SPEC-002 Out of Scope); only explicit per-call echo= ships here.
|
|
33
|
+
_console = ConsoleWriter()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _log(level: str, message: str, echo: bool, fields: dict[str, object]) -> None:
|
|
37
|
+
"""Build one ``level`` event and route it, honoring ``echo``.
|
|
38
|
+
|
|
39
|
+
Inside a span the event is appended to that span's queue (flushed together at span end).
|
|
40
|
+
With no active span (orphan) it becomes a standalone one-event span — fresh ``trace_id``,
|
|
41
|
+
``parent_span_id=None`` — flushed directly so nothing is dropped (FR-004). ``echo`` writes
|
|
42
|
+
the same event as a human-readable console line, additively.
|
|
43
|
+
"""
|
|
44
|
+
baggage = context.get_baggage()
|
|
45
|
+
span = context.current_span()
|
|
46
|
+
if span is not None:
|
|
47
|
+
event = build_event(span, level, message, fields=fields, baggage=baggage)
|
|
48
|
+
span.events.append(event)
|
|
49
|
+
else:
|
|
50
|
+
# Orphan: no active span. Emit a standalone single-event span (fresh trace) so the
|
|
51
|
+
# log is recorded, not silently dropped. Resolve the sink via _ensure_sink (as the
|
|
52
|
+
# decorator does) so a zero-config orphan log falls back to StdoutSink instead of
|
|
53
|
+
# crashing. SPEC-004's worker will later own this direct handoff.
|
|
54
|
+
orphan = Span(
|
|
55
|
+
trace_id=new_trace_id(),
|
|
56
|
+
span_id=new_span_id(),
|
|
57
|
+
parent_span_id=None,
|
|
58
|
+
name=message,
|
|
59
|
+
start_ts=0.0,
|
|
60
|
+
)
|
|
61
|
+
event = build_event(orphan, level, message, fields=fields, baggage=baggage)
|
|
62
|
+
_ensure_sink().emit([event])
|
|
63
|
+
if echo:
|
|
64
|
+
_console.write(event)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def debug(message: str, *, echo: bool = False, **fields: object) -> None:
|
|
68
|
+
"""Emit a ``DEBUG`` event on the current span (or a standalone orphan span)."""
|
|
69
|
+
_log("DEBUG", message, echo, fields)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def info(message: str, *, echo: bool = False, **fields: object) -> None:
|
|
73
|
+
"""Emit an ``INFO`` event on the current span (or a standalone orphan span)."""
|
|
74
|
+
_log("INFO", message, echo, fields)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def warning(message: str, *, echo: bool = False, **fields: object) -> None:
|
|
78
|
+
"""Emit a ``WARNING`` event on the current span (or a standalone orphan span)."""
|
|
79
|
+
_log("WARNING", message, echo, fields)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def error(message: str, *, echo: bool = False, **fields: object) -> None:
|
|
83
|
+
"""Emit an ``ERROR`` event on the current span (or a standalone orphan span)."""
|
|
84
|
+
_log("ERROR", message, echo, fields)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def critical(message: str, *, echo: bool = False, **fields: object) -> None:
|
|
88
|
+
"""Emit a ``CRITICAL`` event on the current span (or a standalone orphan span)."""
|
|
89
|
+
_log("CRITICAL", message, echo, fields)
|
log_forge/config.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Global configuration — set once at startup (arch §7, guide Phase 1).
|
|
2
|
+
|
|
3
|
+
Every log event needs ``service`` / ``version`` / ``env`` stamped on it, and the decorator
|
|
4
|
+
and worker both need to find the configured sink. A single module-level singleton is the
|
|
5
|
+
simplest thing that lets the rest of the code stay decoupled from *how* it was configured.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
# Type-only import: keeps ``config`` free of any runtime dependency on ``sinks``
|
|
15
|
+
# (no import cycle) while still typing the sink field. ``from __future__ import
|
|
16
|
+
# annotations`` means this name is never evaluated at runtime.
|
|
17
|
+
from log_forge.sinks.base import Sink
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Config:
|
|
22
|
+
"""Process-wide settings stamped onto every event / consulted by the pipeline."""
|
|
23
|
+
|
|
24
|
+
service: str = "unknown"
|
|
25
|
+
version: str = "0.0.0"
|
|
26
|
+
env: str = "dev"
|
|
27
|
+
sink: Sink | None = None
|
|
28
|
+
defaults: dict[str, object] = field(default_factory=dict)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
_config = Config() # module-level singleton; the whole library reads through get_config()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def configure(
|
|
35
|
+
*,
|
|
36
|
+
service: str | None = None,
|
|
37
|
+
version: str | None = None,
|
|
38
|
+
env: str | None = None,
|
|
39
|
+
sink: Sink | None = None,
|
|
40
|
+
defaults: dict[str, object] | None = None,
|
|
41
|
+
) -> None:
|
|
42
|
+
"""Patch the global config. Call once at startup.
|
|
43
|
+
|
|
44
|
+
Only the arguments you pass are applied, so repeated calls compose rather than reset.
|
|
45
|
+
If no sink has ever been set, defaults to :class:`~log_forge.sinks.stdout.StdoutSink`
|
|
46
|
+
(the zero-dependency dev default, arch §8) once that phase lands.
|
|
47
|
+
"""
|
|
48
|
+
if service is not None:
|
|
49
|
+
_config.service = service
|
|
50
|
+
if version is not None:
|
|
51
|
+
_config.version = version
|
|
52
|
+
if env is not None:
|
|
53
|
+
_config.env = env
|
|
54
|
+
if sink is not None:
|
|
55
|
+
_config.sink = sink
|
|
56
|
+
if defaults is not None:
|
|
57
|
+
_config.defaults = dict(defaults)
|
|
58
|
+
|
|
59
|
+
_ensure_sink()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def get_config() -> Config:
|
|
63
|
+
"""Return the current global config singleton."""
|
|
64
|
+
return _config
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _ensure_sink() -> Sink:
|
|
68
|
+
"""Return the active sink, applying the ``StdoutSink`` default if none was configured.
|
|
69
|
+
|
|
70
|
+
Centralizes the zero-dependency default (arch §8) so both ``configure()`` and the flush
|
|
71
|
+
path resolve a sink the same way — a decorated call never crashes just because the user
|
|
72
|
+
hasn't called ``configure()`` yet; it emits to stdout. The local import defers the
|
|
73
|
+
``sinks`` dependency and avoids a top-level import cycle (arch §7).
|
|
74
|
+
"""
|
|
75
|
+
if _config.sink is None:
|
|
76
|
+
from log_forge.sinks.stdout import StdoutSink
|
|
77
|
+
|
|
78
|
+
_config.sink = StdoutSink()
|
|
79
|
+
return _config.sink
|
log_forge/console.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Console echo — a synchronous, human-readable output path (SPEC-002 FR-002).
|
|
2
|
+
|
|
3
|
+
Separate from the async :class:`~log_forge.sinks.base.Sink`: where the sink ships structured
|
|
4
|
+
JSON downstream, the console writer surfaces a plain ``LEVEL message`` line *immediately*
|
|
5
|
+
(to a terminal user or a Lambda's stdout → CloudWatch) so an operator sees it without waiting
|
|
6
|
+
for the async flush. It is deliberately dumb — it renders an already-built event dict and
|
|
7
|
+
knows nothing about spans. Echo is additive: an echoed event still rides the normal pipeline
|
|
8
|
+
to the sink.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import sys
|
|
14
|
+
from typing import TextIO
|
|
15
|
+
|
|
16
|
+
__all__ = ["ConsoleWriter"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ConsoleWriter:
|
|
20
|
+
"""Render events as human-readable ``LEVEL message`` lines to ``stream``."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, stream: TextIO | None = None) -> None:
|
|
23
|
+
self._stream = stream if stream is not None else sys.stderr
|
|
24
|
+
|
|
25
|
+
def write(self, event: dict[str, object]) -> None:
|
|
26
|
+
"""Write one ``{level:<7} {message}`` line and flush immediately."""
|
|
27
|
+
level = str(event["level"])
|
|
28
|
+
message = str(event["message"])
|
|
29
|
+
self._stream.write(f"{level:<7} {message}\n")
|
|
30
|
+
self._stream.flush()
|
log_forge/context.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Context propagation: span stack + baggage via contextvars (arch §5, guide Phase 4).
|
|
2
|
+
|
|
3
|
+
``contextvars`` (not thread-locals) is correct under both threads and asyncio — each task
|
|
4
|
+
inherits its own copy — so ``log_forge.info(...)`` can find "the span I'm inside" with no
|
|
5
|
+
manual passing. This module holds a *stack* of active spans (the top is the current span and
|
|
6
|
+
the parent of the next nested call) plus trace-scoped baggage.
|
|
7
|
+
|
|
8
|
+
Two footguns, both avoided below:
|
|
9
|
+
* Never mutate a ContextVar's default mutable value — the ``()`` / ``{}`` defaults are shared
|
|
10
|
+
across all contexts. Always ``.set()`` a new tuple/dict.
|
|
11
|
+
* Use the token/``reset`` pattern, not a manual pop — ``reset(token)`` restores the exact
|
|
12
|
+
prior state even when tasks branch.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import contextvars
|
|
18
|
+
from typing import TYPE_CHECKING
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from log_forge.model import Span
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"current_span",
|
|
25
|
+
"push_span",
|
|
26
|
+
"pop_span",
|
|
27
|
+
"get_baggage",
|
|
28
|
+
"set_baggage",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
_span_stack: contextvars.ContextVar[tuple[Span, ...]] = contextvars.ContextVar(
|
|
32
|
+
"log_forge_span_stack", default=()
|
|
33
|
+
)
|
|
34
|
+
_baggage: contextvars.ContextVar[dict[str, object]] = contextvars.ContextVar(
|
|
35
|
+
"log_forge_baggage", default={}
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def current_span() -> Span | None:
|
|
40
|
+
"""Return the innermost active span, or ``None`` when no span is active."""
|
|
41
|
+
stack = _span_stack.get()
|
|
42
|
+
return stack[-1] if stack else None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def push_span(span: Span) -> contextvars.Token[tuple[Span, ...]]:
|
|
46
|
+
"""Push ``span`` onto the stack; return a token to hand back to :func:`pop_span`."""
|
|
47
|
+
return _span_stack.set((*_span_stack.get(), span))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def pop_span(token: contextvars.Token[tuple[Span, ...]]) -> None:
|
|
51
|
+
"""Restore the span stack to its state before the matching :func:`push_span`."""
|
|
52
|
+
_span_stack.reset(token)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def get_baggage() -> dict[str, object]:
|
|
56
|
+
"""Return the current trace's baggage (do not mutate the returned dict in place)."""
|
|
57
|
+
return _baggage.get()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def set_baggage(**kv: object) -> None:
|
|
61
|
+
"""Merge key/values into the current trace's baggage (replaces with a new dict)."""
|
|
62
|
+
_baggage.set({**_baggage.get(), **kv})
|
log_forge/decorator.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""The ``@trace`` decorator — synchronous and async (arch §4–5, guide Phases 6, 8).
|
|
2
|
+
|
|
3
|
+
Opens a span on enter and closes it on exit (success *or* exception), maintaining the
|
|
4
|
+
trace/parent hierarchy from the context stack, then flushes the finished span straight to
|
|
5
|
+
the configured sink. The decorator is **non-swallowing**: it records the failure and
|
|
6
|
+
re-raises the original exception unchanged (arch §4).
|
|
7
|
+
|
|
8
|
+
At decoration time :func:`asyncio.iscoroutinefunction` selects a sync or async wrapper. The
|
|
9
|
+
two are deliberate near-duplicates — the only difference is ``await fn(...)`` — because the
|
|
10
|
+
sync/async split is a hard boundary; ``contextvars`` already propagates the span stack and
|
|
11
|
+
baggage correctly across ``await`` points and concurrent tasks (arch §5), so the async span
|
|
12
|
+
opens when the coroutine actually runs and closes when it finishes, with no new machinery.
|
|
13
|
+
|
|
14
|
+
Flushing is synchronous here; the background worker (SPEC-004) later swaps only
|
|
15
|
+
:func:`_flush` for a non-blocking handoff — the lifecycle below is untouched.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import asyncio
|
|
21
|
+
import atexit
|
|
22
|
+
import functools
|
|
23
|
+
import threading
|
|
24
|
+
from collections.abc import Callable
|
|
25
|
+
from time import monotonic
|
|
26
|
+
from typing import Any, TypeVar, cast, overload
|
|
27
|
+
|
|
28
|
+
from log_forge import context
|
|
29
|
+
from log_forge.config import _ensure_sink
|
|
30
|
+
from log_forge.ids import new_span_id, new_trace_id
|
|
31
|
+
from log_forge.model import Span, end_event, start_event
|
|
32
|
+
from log_forge.worker import Worker
|
|
33
|
+
|
|
34
|
+
__all__ = ["trace"]
|
|
35
|
+
|
|
36
|
+
# One background worker per process (SPEC-004), created lazily from the configured sink on the
|
|
37
|
+
# first flush. The double-checked lock makes concurrent first-flushes create exactly one.
|
|
38
|
+
_worker: Worker | None = None
|
|
39
|
+
_worker_lock = threading.Lock()
|
|
40
|
+
_atexit_registered = False # register the drain exactly once per process, not per worker
|
|
41
|
+
|
|
42
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _open_span(name: str, defaults: dict[str, object] | None) -> Span:
|
|
46
|
+
"""Mint a span, inheriting the trace/parent from the current context (arch §3)."""
|
|
47
|
+
parent = context.current_span()
|
|
48
|
+
span = Span(
|
|
49
|
+
trace_id=parent.trace_id if parent else new_trace_id(),
|
|
50
|
+
span_id=new_span_id(),
|
|
51
|
+
parent_span_id=parent.span_id if parent else None,
|
|
52
|
+
name=name,
|
|
53
|
+
start_ts=monotonic(),
|
|
54
|
+
defaults=defaults or {},
|
|
55
|
+
)
|
|
56
|
+
span.events.append(start_event(span))
|
|
57
|
+
return span
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _get_worker() -> Worker:
|
|
61
|
+
"""Return the process worker, creating it lazily from the configured sink (FR-006).
|
|
62
|
+
|
|
63
|
+
Registers the graceful drain via ``atexit`` exactly once, on first creation, so a program
|
|
64
|
+
that logs and exits immediately still flushes its buffered events.
|
|
65
|
+
"""
|
|
66
|
+
global _worker, _atexit_registered
|
|
67
|
+
if _worker is None:
|
|
68
|
+
with _worker_lock:
|
|
69
|
+
if _worker is None:
|
|
70
|
+
if not _atexit_registered:
|
|
71
|
+
atexit.register(_shutdown_worker)
|
|
72
|
+
_atexit_registered = True
|
|
73
|
+
_worker = Worker(_ensure_sink())
|
|
74
|
+
return _worker
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _shutdown_worker() -> None:
|
|
78
|
+
"""Drain + close the process worker if one was created (idempotent). Backs ``shutdown()``."""
|
|
79
|
+
if _worker is not None:
|
|
80
|
+
_worker.shutdown()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _flush(span: Span) -> None:
|
|
84
|
+
"""Hand the finished span's events to the background worker — non-blocking (FR-001).
|
|
85
|
+
|
|
86
|
+
Resolves/creates the worker via :func:`_get_worker` (whose sink comes from ``_ensure_sink``,
|
|
87
|
+
so a zero-config ``@trace`` still falls back to ``StdoutSink`` rather than crashing).
|
|
88
|
+
"""
|
|
89
|
+
_get_worker().submit(span.events)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _close_span(span: Span, status: str, exc: BaseException | None) -> None:
|
|
93
|
+
"""Append the end event (so the flushed queue is complete), then flush."""
|
|
94
|
+
span.events.append(end_event(span, status, exc))
|
|
95
|
+
_flush(span)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@overload
|
|
99
|
+
def trace(func: F) -> F: ...
|
|
100
|
+
@overload
|
|
101
|
+
def trace(
|
|
102
|
+
*, name: str | None = ..., defaults: dict[str, object] | None = ...
|
|
103
|
+
) -> Callable[[F], F]: ...
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def trace(
|
|
107
|
+
func: F | None = None,
|
|
108
|
+
*,
|
|
109
|
+
name: str | None = None,
|
|
110
|
+
defaults: dict[str, object] | None = None,
|
|
111
|
+
) -> F | Callable[[F], F]:
|
|
112
|
+
"""Trace a function call as a span. Usable bare (``@trace``) or with args.
|
|
113
|
+
|
|
114
|
+
``@trace(name=..., defaults=...)`` overrides the span name (default:
|
|
115
|
+
``func.__qualname__``) and adds per-decorator default fields.
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
def decorate(fn: F) -> F:
|
|
119
|
+
if asyncio.iscoroutinefunction(fn):
|
|
120
|
+
|
|
121
|
+
@functools.wraps(fn)
|
|
122
|
+
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
123
|
+
span = _open_span(name or fn.__qualname__, defaults)
|
|
124
|
+
token = context.push_span(span)
|
|
125
|
+
try:
|
|
126
|
+
result = await fn(*args, **kwargs)
|
|
127
|
+
_close_span(span, "ok", None)
|
|
128
|
+
return result
|
|
129
|
+
except BaseException as exc:
|
|
130
|
+
# Non-swallowing (arch §4): record the error, then re-raise unchanged.
|
|
131
|
+
# BaseException covers asyncio.CancelledError — a cancelled coroutine is
|
|
132
|
+
# recorded as an error end event, never left with an unclosed span.
|
|
133
|
+
_close_span(span, "error", exc)
|
|
134
|
+
raise
|
|
135
|
+
finally:
|
|
136
|
+
context.pop_span(token)
|
|
137
|
+
|
|
138
|
+
return cast(F, async_wrapper)
|
|
139
|
+
|
|
140
|
+
@functools.wraps(fn)
|
|
141
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
142
|
+
span = _open_span(name or fn.__qualname__, defaults)
|
|
143
|
+
token = context.push_span(span)
|
|
144
|
+
try:
|
|
145
|
+
result = fn(*args, **kwargs)
|
|
146
|
+
_close_span(span, "ok", None)
|
|
147
|
+
return result
|
|
148
|
+
except BaseException as exc:
|
|
149
|
+
# Non-swallowing (arch §4): record the error, then re-raise unchanged.
|
|
150
|
+
_close_span(span, "error", exc)
|
|
151
|
+
raise
|
|
152
|
+
finally:
|
|
153
|
+
context.pop_span(token)
|
|
154
|
+
|
|
155
|
+
return cast(F, wrapper)
|
|
156
|
+
|
|
157
|
+
return decorate(func) if func is not None else decorate
|
log_forge/ids.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""W3C Trace Context-compatible id generation (arch §3.1, guide Phase 2).
|
|
2
|
+
|
|
3
|
+
``trace_id`` and ``span_id`` use the W3C wire formats (not arbitrary UUIDs) so a future
|
|
4
|
+
"adopt the inbound request's trace" feature is just a header parse (arch §12) and the
|
|
5
|
+
emitted records stay compatible with standard distributed-tracing tooling. ``log_id`` is
|
|
6
|
+
internal-only, so a UUID is fine.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import uuid
|
|
13
|
+
|
|
14
|
+
__all__ = ["new_trace_id", "new_span_id", "new_log_id"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def new_trace_id() -> str:
|
|
18
|
+
"""Return a fresh trace id: 32 lowercase hex chars (16 random bytes)."""
|
|
19
|
+
return os.urandom(16).hex()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def new_span_id() -> str:
|
|
23
|
+
"""Return a fresh span id: 16 lowercase hex chars (8 random bytes)."""
|
|
24
|
+
return os.urandom(8).hex()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def new_log_id() -> str:
|
|
28
|
+
"""Return a fresh log id: a UUID4 hex string (internal-only, format is our choice)."""
|
|
29
|
+
return uuid.uuid4().hex
|
log_forge/model.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Span model + log-event construction and serialization (arch §6, guide Phase 3).
|
|
2
|
+
|
|
3
|
+
This is the heart of "structured, never free-form": every event is assembled here into one
|
|
4
|
+
identical JSON shape, which is what makes logs queryable downstream. This module only
|
|
5
|
+
*builds* records — it deliberately imports neither ``context`` nor ``decorator`` and does
|
|
6
|
+
not know where the "current" span lives (arch §6 watch-out).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import time
|
|
12
|
+
import traceback
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
|
|
16
|
+
__all__ = ["Span", "build_event", "start_event", "end_event"]
|
|
17
|
+
|
|
18
|
+
# Auto-generated span-boundary event messages. Tests assert on contract fields
|
|
19
|
+
# (``status``, ``trace_id``), never on this text — rename freely.
|
|
20
|
+
_START_MESSAGE = "span.start"
|
|
21
|
+
_END_MESSAGE = "span.end"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class Span:
|
|
26
|
+
"""One decorated function call: its identity, timing, and buffered events.
|
|
27
|
+
|
|
28
|
+
``start_ts`` is a ``time.monotonic()`` reading (not wall-clock) so ``duration_ms`` can
|
|
29
|
+
never go negative across a clock change. ``defaults`` are per-decorator field overrides;
|
|
30
|
+
``events`` is the queue flushed together at span end.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
trace_id: str
|
|
34
|
+
span_id: str
|
|
35
|
+
parent_span_id: str | None
|
|
36
|
+
name: str
|
|
37
|
+
start_ts: float
|
|
38
|
+
defaults: dict[str, object] = field(default_factory=dict)
|
|
39
|
+
events: list[dict[str, object]] = field(default_factory=list)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _iso_now() -> str:
|
|
43
|
+
"""Return the current UTC time as ISO-8601 with millisecond precision and a 'Z'."""
|
|
44
|
+
now = datetime.now(timezone.utc)
|
|
45
|
+
return f"{now:%Y-%m-%dT%H:%M:%S}.{now.microsecond // 1000:03d}Z"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def build_event(
|
|
49
|
+
span: Span,
|
|
50
|
+
level: str,
|
|
51
|
+
message: str,
|
|
52
|
+
*,
|
|
53
|
+
fields: dict[str, object],
|
|
54
|
+
baggage: dict[str, object],
|
|
55
|
+
) -> dict[str, object]:
|
|
56
|
+
"""Assemble one log record in the arch §6 schema.
|
|
57
|
+
|
|
58
|
+
Field precedence, lowest to highest: config ``defaults`` → ``span.defaults`` → ``baggage``
|
|
59
|
+
→ per-call ``fields`` (arch §5.1). Later sources win on a key conflict.
|
|
60
|
+
"""
|
|
61
|
+
from log_forge.config import get_config
|
|
62
|
+
from log_forge.ids import new_log_id
|
|
63
|
+
|
|
64
|
+
cfg = get_config()
|
|
65
|
+
merged: dict[str, object] = {**cfg.defaults, **span.defaults, **baggage, **fields}
|
|
66
|
+
return {
|
|
67
|
+
"timestamp": _iso_now(),
|
|
68
|
+
"level": level,
|
|
69
|
+
"message": message,
|
|
70
|
+
"trace_id": span.trace_id,
|
|
71
|
+
"span_id": span.span_id,
|
|
72
|
+
"parent_span_id": span.parent_span_id,
|
|
73
|
+
"log_id": new_log_id(),
|
|
74
|
+
"function": span.name,
|
|
75
|
+
"service": cfg.service,
|
|
76
|
+
"version": cfg.version,
|
|
77
|
+
"env": cfg.env,
|
|
78
|
+
"fields": merged,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def start_event(span: Span) -> dict[str, object]:
|
|
83
|
+
"""Build the span-start boundary event."""
|
|
84
|
+
return build_event(span, "INFO", _START_MESSAGE, fields={}, baggage={})
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def end_event(
|
|
88
|
+
span: Span,
|
|
89
|
+
status: str,
|
|
90
|
+
exc: BaseException | None = None,
|
|
91
|
+
) -> dict[str, object]:
|
|
92
|
+
"""Build the span-end boundary event.
|
|
93
|
+
|
|
94
|
+
Adds ``duration_ms`` (from a monotonic delta), ``status`` (``"ok"``/``"error"``), and on
|
|
95
|
+
failure a nested ``error`` with the exception type and formatted stack (arch §6).
|
|
96
|
+
"""
|
|
97
|
+
level = "INFO" if status == "ok" else "ERROR"
|
|
98
|
+
event = build_event(span, level, _END_MESSAGE, fields={}, baggage={})
|
|
99
|
+
event["duration_ms"] = (time.monotonic() - span.start_ts) * 1000.0
|
|
100
|
+
event["status"] = status
|
|
101
|
+
if exc is not None:
|
|
102
|
+
event["error"] = {
|
|
103
|
+
"type": type(exc).__name__,
|
|
104
|
+
"stack": "".join(traceback.format_exception(type(exc), exc, exc.__traceback__)),
|
|
105
|
+
}
|
|
106
|
+
return event
|
log_forge/py.typed
ADDED
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Shared batch-chunking for the queue/stream sinks (SPEC-010).
|
|
2
|
+
|
|
3
|
+
The worker batches by its own count/time; each transport then re-chunks to *its* per-request limits
|
|
4
|
+
(no assumption the incoming batch already fits). This mirrors the SPEC-005 ``SQSSink`` conventions:
|
|
5
|
+
split into groups within a count limit **and** a total-byte limit, assuming each item already fits a
|
|
6
|
+
single request (callers drop oversized items first, counting ``dropped_oversized``).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from collections.abc import Callable, Iterator
|
|
13
|
+
from typing import TypeVar
|
|
14
|
+
|
|
15
|
+
__all__ = ["chunk_items", "chunk_list", "valid_identifier"]
|
|
16
|
+
|
|
17
|
+
_T = TypeVar("_T")
|
|
18
|
+
|
|
19
|
+
_IDENTIFIER = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def chunk_list(items: list[_T], size: int) -> Iterator[list[_T]]:
|
|
23
|
+
"""Yield ``items`` in successive slices of at most ``size`` (a positive chunk size)."""
|
|
24
|
+
for start in range(0, len(items), size):
|
|
25
|
+
yield items[start : start + size]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def valid_identifier(name: str) -> str:
|
|
29
|
+
"""Return ``name`` if it is a plain SQL identifier, else raise ``ValueError``.
|
|
30
|
+
|
|
31
|
+
Table names are config, not untrusted input, but they are interpolated into DDL/DML (SQL cannot
|
|
32
|
+
parameterize identifiers), so restrict them to ``[A-Za-z_][A-Za-z0-9_]*`` to foreclose injection.
|
|
33
|
+
"""
|
|
34
|
+
if not _IDENTIFIER.match(name):
|
|
35
|
+
raise ValueError(f"invalid table name {name!r}; expected a plain SQL identifier")
|
|
36
|
+
return name
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def chunk_items(
|
|
40
|
+
items: list[_T], *, max_count: int, max_bytes: int, size_of: Callable[[_T], int]
|
|
41
|
+
) -> Iterator[list[_T]]:
|
|
42
|
+
"""Yield groups of ≤ ``max_count`` items whose ``size_of`` sizes sum to ≤ ``max_bytes``.
|
|
43
|
+
|
|
44
|
+
Each item is assumed to fit one request on its own (``size_of(item) <= max_bytes``); oversized
|
|
45
|
+
items are the caller's responsibility to filter out beforehand (counting ``dropped_oversized``).
|
|
46
|
+
"""
|
|
47
|
+
current: list[_T] = []
|
|
48
|
+
current_bytes = 0
|
|
49
|
+
for item in items:
|
|
50
|
+
size = size_of(item)
|
|
51
|
+
if current and (len(current) >= max_count or current_bytes + size > max_bytes):
|
|
52
|
+
yield current
|
|
53
|
+
current = []
|
|
54
|
+
current_bytes = 0
|
|
55
|
+
current.append(item)
|
|
56
|
+
current_bytes += size
|
|
57
|
+
if current:
|
|
58
|
+
yield current
|