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.
Files changed (51) hide show
  1. log_forge/__init__.py +45 -0
  2. log_forge/api.py +89 -0
  3. log_forge/config.py +79 -0
  4. log_forge/console.py +30 -0
  5. log_forge/context.py +62 -0
  6. log_forge/decorator.py +157 -0
  7. log_forge/ids.py +29 -0
  8. log_forge/model.py +106 -0
  9. log_forge/py.typed +0 -0
  10. log_forge/sinks/__init__.py +0 -0
  11. log_forge/sinks/_chunk.py +58 -0
  12. log_forge/sinks/_socket.py +105 -0
  13. log_forge/sinks/_time.py +28 -0
  14. log_forge/sinks/base.py +22 -0
  15. log_forge/sinks/callback.py +42 -0
  16. log_forge/sinks/clickhouse.py +111 -0
  17. log_forge/sinks/datadog.py +53 -0
  18. log_forge/sinks/elasticsearch.py +68 -0
  19. log_forge/sinks/eventhubs.py +110 -0
  20. log_forge/sinks/file.py +177 -0
  21. log_forge/sinks/filtering.py +71 -0
  22. log_forge/sinks/firehose.py +91 -0
  23. log_forge/sinks/honeycomb.py +38 -0
  24. log_forge/sinks/http.py +202 -0
  25. log_forge/sinks/kafka.py +71 -0
  26. log_forge/sinks/kinesis.py +99 -0
  27. log_forge/sinks/logging_sink.py +104 -0
  28. log_forge/sinks/logstash.py +73 -0
  29. log_forge/sinks/loki.py +48 -0
  30. log_forge/sinks/mongodb.py +105 -0
  31. log_forge/sinks/multi.py +53 -0
  32. log_forge/sinks/nats.py +72 -0
  33. log_forge/sinks/newrelic.py +28 -0
  34. log_forge/sinks/postgres.py +101 -0
  35. log_forge/sinks/pubsub.py +45 -0
  36. log_forge/sinks/rabbitmq.py +129 -0
  37. log_forge/sinks/redis.py +90 -0
  38. log_forge/sinks/sentry.py +136 -0
  39. log_forge/sinks/sns.py +82 -0
  40. log_forge/sinks/splunk.py +51 -0
  41. log_forge/sinks/sqlite.py +100 -0
  42. log_forge/sinks/sqs.py +107 -0
  43. log_forge/sinks/stdout.py +31 -0
  44. log_forge/sinks/syslog.py +88 -0
  45. log_forge/sinks/transform.py +46 -0
  46. log_forge/sinks/util.py +68 -0
  47. log_forge/worker.py +153 -0
  48. log_foundry-0.0.2.dev1.dist-info/METADATA +537 -0
  49. log_foundry-0.0.2.dev1.dist-info/RECORD +51 -0
  50. log_foundry-0.0.2.dev1.dist-info/WHEEL +4 -0
  51. 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