log-foundry 0.10.2.dev108__tar.gz → 0.10.2.dev110__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. {log_foundry-0.10.2.dev108 → log_foundry-0.10.2.dev110}/PKG-INFO +1 -1
  2. {log_foundry-0.10.2.dev108 → log_foundry-0.10.2.dev110}/pyproject.toml +1 -1
  3. {log_foundry-0.10.2.dev108 → log_foundry-0.10.2.dev110}/src/log_foundry/__init__.py +18 -4
  4. {log_foundry-0.10.2.dev108 → log_foundry-0.10.2.dev110}/src/log_foundry/config.py +7 -3
  5. {log_foundry-0.10.2.dev108 → log_foundry-0.10.2.dev110}/src/log_foundry/context.py +8 -5
  6. {log_foundry-0.10.2.dev108 → log_foundry-0.10.2.dev110}/src/log_foundry/decorator.py +11 -5
  7. {log_foundry-0.10.2.dev108 → log_foundry-0.10.2.dev110}/src/log_foundry/results.py +1 -1
  8. {log_foundry-0.10.2.dev108 → log_foundry-0.10.2.dev110}/src/log_foundry/sinks/base.py +9 -1
  9. {log_foundry-0.10.2.dev108 → log_foundry-0.10.2.dev110}/src/log_foundry/sinks/datadog.py +9 -4
  10. {log_foundry-0.10.2.dev108 → log_foundry-0.10.2.dev110}/src/log_foundry/sinks/elasticsearch.py +8 -4
  11. {log_foundry-0.10.2.dev108 → log_foundry-0.10.2.dev110}/src/log_foundry/sinks/filtering.py +3 -3
  12. {log_foundry-0.10.2.dev108 → log_foundry-0.10.2.dev110}/src/log_foundry/sinks/honeycomb.py +9 -4
  13. {log_foundry-0.10.2.dev108 → log_foundry-0.10.2.dev110}/src/log_foundry/sinks/http.py +60 -7
  14. {log_foundry-0.10.2.dev108 → log_foundry-0.10.2.dev110}/src/log_foundry/sinks/logstash.py +9 -5
  15. {log_foundry-0.10.2.dev108 → log_foundry-0.10.2.dev110}/src/log_foundry/sinks/loki.py +7 -4
  16. {log_foundry-0.10.2.dev108 → log_foundry-0.10.2.dev110}/src/log_foundry/sinks/newrelic.py +13 -4
  17. {log_foundry-0.10.2.dev108 → log_foundry-0.10.2.dev110}/src/log_foundry/sinks/sentry.py +6 -2
  18. {log_foundry-0.10.2.dev108 → log_foundry-0.10.2.dev110}/src/log_foundry/sinks/splunk.py +7 -4
  19. {log_foundry-0.10.2.dev108 → log_foundry-0.10.2.dev110}/src/log_foundry/sinks/sqs.py +4 -1
  20. {log_foundry-0.10.2.dev108 → log_foundry-0.10.2.dev110}/src/log_foundry/sinks/transform.py +3 -3
  21. {log_foundry-0.10.2.dev108 → log_foundry-0.10.2.dev110}/src/log_foundry/worker.py +12 -7
  22. {log_foundry-0.10.2.dev108 → log_foundry-0.10.2.dev110}/LICENSE +0 -0
  23. {log_foundry-0.10.2.dev108 → log_foundry-0.10.2.dev110}/README.md +0 -0
  24. {log_foundry-0.10.2.dev108 → log_foundry-0.10.2.dev110}/src/log_foundry/_diag.py +0 -0
  25. {log_foundry-0.10.2.dev108 → log_foundry-0.10.2.dev110}/src/log_foundry/_fork.py +0 -0
  26. {log_foundry-0.10.2.dev108 → log_foundry-0.10.2.dev110}/src/log_foundry/_lifecycle.py +0 -0
  27. {log_foundry-0.10.2.dev108 → log_foundry-0.10.2.dev110}/src/log_foundry/api.py +0 -0
  28. {log_foundry-0.10.2.dev108 → log_foundry-0.10.2.dev110}/src/log_foundry/console.py +0 -0
  29. {log_foundry-0.10.2.dev108 → log_foundry-0.10.2.dev110}/src/log_foundry/ids.py +0 -0
  30. {log_foundry-0.10.2.dev108 → log_foundry-0.10.2.dev110}/src/log_foundry/model.py +0 -0
  31. {log_foundry-0.10.2.dev108 → log_foundry-0.10.2.dev110}/src/log_foundry/py.typed +0 -0
  32. {log_foundry-0.10.2.dev108 → log_foundry-0.10.2.dev110}/src/log_foundry/sanitize.py +0 -0
  33. {log_foundry-0.10.2.dev108 → log_foundry-0.10.2.dev110}/src/log_foundry/sinks/__init__.py +0 -0
  34. {log_foundry-0.10.2.dev108 → log_foundry-0.10.2.dev110}/src/log_foundry/sinks/_batch.py +0 -0
  35. {log_foundry-0.10.2.dev108 → log_foundry-0.10.2.dev110}/src/log_foundry/sinks/_chunk.py +0 -0
  36. {log_foundry-0.10.2.dev108 → log_foundry-0.10.2.dev110}/src/log_foundry/sinks/_retry.py +0 -0
  37. {log_foundry-0.10.2.dev108 → log_foundry-0.10.2.dev110}/src/log_foundry/sinks/_socket.py +0 -0
  38. {log_foundry-0.10.2.dev108 → log_foundry-0.10.2.dev110}/src/log_foundry/sinks/_time.py +0 -0
  39. {log_foundry-0.10.2.dev108 → log_foundry-0.10.2.dev110}/src/log_foundry/sinks/callback.py +0 -0
  40. {log_foundry-0.10.2.dev108 → log_foundry-0.10.2.dev110}/src/log_foundry/sinks/clickhouse.py +0 -0
  41. {log_foundry-0.10.2.dev108 → log_foundry-0.10.2.dev110}/src/log_foundry/sinks/eventhubs.py +0 -0
  42. {log_foundry-0.10.2.dev108 → log_foundry-0.10.2.dev110}/src/log_foundry/sinks/file.py +0 -0
  43. {log_foundry-0.10.2.dev108 → log_foundry-0.10.2.dev110}/src/log_foundry/sinks/firehose.py +0 -0
  44. {log_foundry-0.10.2.dev108 → log_foundry-0.10.2.dev110}/src/log_foundry/sinks/kafka.py +0 -0
  45. {log_foundry-0.10.2.dev108 → log_foundry-0.10.2.dev110}/src/log_foundry/sinks/kinesis.py +0 -0
  46. {log_foundry-0.10.2.dev108 → log_foundry-0.10.2.dev110}/src/log_foundry/sinks/logging_sink.py +0 -0
  47. {log_foundry-0.10.2.dev108 → log_foundry-0.10.2.dev110}/src/log_foundry/sinks/memory.py +0 -0
  48. {log_foundry-0.10.2.dev108 → log_foundry-0.10.2.dev110}/src/log_foundry/sinks/mongodb.py +0 -0
  49. {log_foundry-0.10.2.dev108 → log_foundry-0.10.2.dev110}/src/log_foundry/sinks/multi.py +0 -0
  50. {log_foundry-0.10.2.dev108 → log_foundry-0.10.2.dev110}/src/log_foundry/sinks/nats.py +0 -0
  51. {log_foundry-0.10.2.dev108 → log_foundry-0.10.2.dev110}/src/log_foundry/sinks/null.py +0 -0
  52. {log_foundry-0.10.2.dev108 → log_foundry-0.10.2.dev110}/src/log_foundry/sinks/postgres.py +0 -0
  53. {log_foundry-0.10.2.dev108 → log_foundry-0.10.2.dev110}/src/log_foundry/sinks/pubsub.py +0 -0
  54. {log_foundry-0.10.2.dev108 → log_foundry-0.10.2.dev110}/src/log_foundry/sinks/rabbitmq.py +0 -0
  55. {log_foundry-0.10.2.dev108 → log_foundry-0.10.2.dev110}/src/log_foundry/sinks/redis.py +0 -0
  56. {log_foundry-0.10.2.dev108 → log_foundry-0.10.2.dev110}/src/log_foundry/sinks/sns.py +0 -0
  57. {log_foundry-0.10.2.dev108 → log_foundry-0.10.2.dev110}/src/log_foundry/sinks/sqlite.py +0 -0
  58. {log_foundry-0.10.2.dev108 → log_foundry-0.10.2.dev110}/src/log_foundry/sinks/stdout.py +0 -0
  59. {log_foundry-0.10.2.dev108 → log_foundry-0.10.2.dev110}/src/log_foundry/sinks/syslog.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: log-foundry
3
- Version: 0.10.2.dev108
3
+ Version: 0.10.2.dev110
4
4
  Summary: Generate logs for your console and JSON events for downstream consumption.
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -74,7 +74,7 @@ keywords = [
74
74
  # vulnerability-reporting channel. The repository is still named `log-forge` — the ORIGINAL name,
75
75
  # which PyPI rejected for the distribution — so these URLs deliberately do not match the package
76
76
  # name. See the note on `name` above before "correcting" them.
77
- version = "0.10.2.dev108"
77
+ version = "0.10.2.dev110"
78
78
 
79
79
  [project.urls]
80
80
  Homepage = "https://github.com/agriffi10/log-forge"
@@ -15,8 +15,14 @@ from log_foundry.context import (
15
15
  )
16
16
  from log_foundry.decorator import continue_trace, trace
17
17
  from log_foundry.results import ContinueResult, FlushResult
18
- from log_foundry.sinks.base import Sink, SinkDeliveryError, SinkLosses, read_losses
19
- from log_foundry.worker import DEFAULT_SHUTDOWN_TIMEOUT, Health
18
+ from log_foundry.sinks.base import (
19
+ Sink,
20
+ SinkDeliveryError,
21
+ SinkLosses,
22
+ flush_sink,
23
+ read_losses,
24
+ )
25
+ from log_foundry.worker import DEFAULT_SHUTDOWN_TIMEOUT, DEFAULT_SWAP_TIMEOUT, Health
20
26
 
21
27
  try:
22
28
  __version__ = _dist_version("log-foundry")
@@ -95,7 +101,9 @@ def health() -> Health:
95
101
 
96
102
  Returns:
97
103
  The snapshot: ``queued``, ``dropped``, ``failed_batches``, ``stopped_reason``, ``sink``,
98
- ``retired``, ``submitted_after_shutdown`` and ``incomplete_swaps``. ``retired`` says
104
+ ``retired``, ``submitted_after_shutdown``, ``incomplete_swaps``, ``closing_sinks``,
105
+ ``inherited_sink``, ``orphan_lost`` and ``in_span_lost`` — every field, since a counter
106
+ documented nowhere is a loss nobody looks for. ``retired`` says
99
107
  :func:`shutdown` was called, and ``submitted_after_shutdown`` counts events accepted
100
108
  afterwards, which are queued where nothing will drain them — non-zero together, that is
101
109
  the ``shutdown()``-per-invocation mistake, and the remedy is :func:`flush`.
@@ -112,7 +120,11 @@ def health() -> Health:
112
120
  Its ``dropped`` is not the worker's: the worker's is backpressure at the queue, the
113
121
  sink's is an event that never reached the wire, and the stderr line names which. Its
114
122
  ``failed`` is an upper bound on loss rather than a count of it, since a sink that raises
115
- on total failure counts the attempt and hands the batch back for the worker to retry. A
123
+ on total failure counts the attempt and hands the batch back for the worker to retry.
124
+ ``inherited_sink`` is a state and not a fault: it says **whether** the sink this process
125
+ last installed for delivery is one it inherited across a ``fork`` and may not release,
126
+ which is what explains a handle still open after :func:`shutdown` (SPEC-042). ``orphan_lost`` and ``in_span_lost``
127
+ are the two terms above that describe no worker at all, and the alert idiom ends on them. A
116
128
  process that has never logged has no worker, and asking does not create one — the
117
129
  snapshot is simply zeroed, except for ``retired``, which stays truthful even for a
118
130
  process that only ever logged outside a span and so built no worker at all (SPEC-031
@@ -176,6 +188,7 @@ def shutdown(timeout: float | None = DEFAULT_SHUTDOWN_TIMEOUT) -> None:
176
188
 
177
189
  __all__ = [
178
190
  "DEFAULT_SHUTDOWN_TIMEOUT",
191
+ "DEFAULT_SWAP_TIMEOUT",
179
192
  "Config",
180
193
  "ContinueResult",
181
194
  "FlushResult",
@@ -193,6 +206,7 @@ __all__ = [
193
206
  "debug",
194
207
  "error",
195
208
  "flush",
209
+ "flush_sink",
196
210
  "get_baggage",
197
211
  "get_config",
198
212
  "health",
@@ -9,10 +9,12 @@ from typing import TYPE_CHECKING
9
9
  from log_foundry import _lifecycle
10
10
 
11
11
  if TYPE_CHECKING:
12
+ from collections.abc import Mapping
13
+
12
14
  from log_foundry.sinks.base import Sink
13
15
 
14
16
 
15
- @dataclass(frozen=True)
17
+ @dataclass(frozen=True, kw_only=True)
16
18
  class Config:
17
19
  """Process-wide settings stamped onto every event and consulted by the pipeline.
18
20
 
@@ -92,7 +94,7 @@ def configure(
92
94
  version: str | None = None,
93
95
  env: str | None = None,
94
96
  sink: Sink | None = None,
95
- defaults: dict[str, object] | None = None,
97
+ defaults: Mapping[str, object] | None = None,
96
98
  max_value_bytes: int | None = None,
97
99
  max_stack_bytes: int | None = None,
98
100
  max_keys: int | None = None,
@@ -147,7 +149,9 @@ def configure(
147
149
  sink: The destination every event is delivered to. Passed after the first log, it swaps
148
150
  the live target as described above rather than only updating what ``get_config()``
149
151
  reports.
150
- defaults: Fields merged into every event at the lowest precedence.
152
+ defaults: Fields merged into every event at the lowest precedence. Any ``Mapping`` is
153
+ accepted and a copy is stored, so a later edit to the caller's own object does not
154
+ reach the config (SPEC-051 FR-002).
151
155
  max_value_bytes: Per-value ceiling, in UTF-8 bytes or rendered digits.
152
156
  max_stack_bytes: Ceiling for ``error.stack`` alone.
153
157
  max_keys: Ceiling on the entries of one mapping or sequence.
@@ -13,17 +13,20 @@ if TYPE_CHECKING:
13
13
 
14
14
  __all__ = [
15
15
  "current_baggage_header",
16
- "current_span",
17
16
  "current_trace_context",
18
17
  "current_traceparent",
19
18
  "get_baggage",
20
- "pop_baggage_scope",
21
- "pop_span",
22
- "push_baggage_scope",
23
- "push_span",
24
19
  "reset_context",
25
20
  "set_baggage",
26
21
  ]
22
+ """Exactly the six names ``log_foundry`` re-exports (SPEC-051 FR-003).
23
+
24
+ The five that left -- ``current_span``, ``push_span``, ``pop_span``, ``push_baggage_scope``
25
+ and ``pop_baggage_scope`` -- are internal and stay importable; what was withdrawn is the
26
+ claim, not the symbol. ``current_span`` is why this matters: it hands back a **mutable**
27
+ :class:`~.model.Span`, which is what :func:`current_trace_context` exists to keep callers
28
+ away from, and a name still in ``__all__`` at ``1.0`` could not be withdrawn afterwards.
29
+ """
27
30
 
28
31
  _span_stack: contextvars.ContextVar[tuple[Span, ...]] = contextvars.ContextVar(
29
32
  "log_foundry_span_stack", default=()
@@ -22,6 +22,7 @@ from log_foundry.results import ContinueResult
22
22
 
23
23
  if TYPE_CHECKING:
24
24
  import contextvars
25
+ from collections.abc import Mapping
25
26
 
26
27
 
27
28
  __all__ = ["continue_trace", "trace"]
@@ -631,7 +632,7 @@ def _close_span(span: Span, status: str, exc: BaseException | None) -> None:
631
632
  def trace(func: F) -> F: ...
632
633
  @overload
633
634
  def trace(
634
- *, name: str | None = ..., defaults: dict[str, object] | None = ...
635
+ *, name: str | None = ..., defaults: Mapping[str, object] | None = ...
635
636
  ) -> Callable[[F], F]: ...
636
637
 
637
638
 
@@ -639,14 +640,18 @@ def trace(
639
640
  func: F | None = None,
640
641
  *,
641
642
  name: str | None = None,
642
- defaults: dict[str, object] | None = None,
643
+ defaults: Mapping[str, object] | None = None,
643
644
  ) -> F | Callable[[F], F]:
644
645
  """Traces a function call as a span, usable bare as ``@trace`` or with arguments.
645
646
 
646
647
  Args:
647
648
  func: The function being decorated when used bare, otherwise ``None``.
648
649
  name: Overrides the span name, which defaults to ``func.__qualname__``.
649
- defaults: Per-decorator default fields added to every event on the span.
650
+ defaults: Per-decorator default fields added to every event on the span. Any
651
+ ``Mapping`` is accepted, and a copy is taken **once here**, at decoration — not per
652
+ call. Before SPEC-051 FR-002 the caller's own object was bound to every span and read
653
+ on the per-event path, so editing it after decoration changed later spans; that is
654
+ tolerable for a ``dict`` and is not for a ``Mapping`` whose ``keys()`` is user code.
650
655
 
651
656
  Returns:
652
657
  The wrapped function, or a decorator when called with arguments.
@@ -654,6 +659,7 @@ def trace(
654
659
  Raises:
655
660
  None.
656
661
  """
662
+ span_defaults = None if defaults is None else dict(defaults)
657
663
 
658
664
  def decorate(fn: F) -> F:
659
665
  """Wraps one function, selecting the sync or async wrapper at decoration time.
@@ -698,7 +704,7 @@ def trace(
698
704
  Raises:
699
705
  BaseException: Whatever the wrapped coroutine raises, unchanged.
700
706
  """
701
- span, token, scope = _begin(name or fn.__qualname__, defaults)
707
+ span, token, scope = _begin(name or fn.__qualname__, span_defaults)
702
708
  status, error = "ok", None
703
709
  try:
704
710
  result = await fn(*args, **kwargs)
@@ -734,7 +740,7 @@ def trace(
734
740
  Raises:
735
741
  BaseException: Whatever the wrapped function raises, unchanged.
736
742
  """
737
- span, token, scope = _begin(name or fn.__qualname__, defaults)
743
+ span, token, scope = _begin(name or fn.__qualname__, span_defaults)
738
744
  status, error = "ok", None
739
745
  try:
740
746
  result = fn(*args, **kwargs)
@@ -7,7 +7,7 @@ from dataclasses import dataclass
7
7
  __all__ = ["ContinueResult", "FlushResult"]
8
8
 
9
9
 
10
- @dataclass(frozen=True)
10
+ @dataclass(frozen=True, kw_only=True)
11
11
  class _Result:
12
12
  """A verdict that reads as a boolean and can say why (SPEC-034 FR-007).
13
13
 
@@ -20,7 +20,7 @@ class SinkDeliveryError(Exception):
20
20
  """
21
21
 
22
22
 
23
- @dataclass(frozen=True)
23
+ @dataclass(frozen=True, kw_only=True)
24
24
  class SinkLosses:
25
25
  """What a sink discarded or could not confirm, cumulative for its lifetime (FR-002).
26
26
 
@@ -30,6 +30,14 @@ class SinkLosses:
30
30
  lost. ``health().failed_batches`` is the worker-level record of a batch given up on for
31
31
  good; this is the sink-level record of everything that did not go through first time.
32
32
 
33
+ Construction is **keyword-only** (SPEC-051 FR-001), which is a break for a sink written
34
+ against ``0.x``: passing the two counters positionally now raises, and because
35
+ :func:`read_losses`
36
+ swallows a raising accessor by design, that sink's loss reporting degrades to ``None`` —
37
+ "reports nothing" — rather than to an error anyone sees. One keyword each is the fix. The
38
+ same decision empties ``__match_args__``, so a positional ``case SinkLosses(d, f):`` no
39
+ longer matches while ``case SinkLosses(dropped=d, failed=f):`` still does.
40
+
33
41
  Attributes:
34
42
  dropped: An event the sink discarded before attempting delivery, usually one the
35
43
  destination could never have accepted as built, so the fix is upstream in what the
@@ -3,8 +3,9 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
+ from typing import Unpack
6
7
 
7
- from log_foundry.sinks.http import HTTPSink, merge_headers
8
+ from log_foundry.sinks.http import HTTPPlatformKwargs, HTTPSink, merge_headers
8
9
 
9
10
  __all__ = ["DatadogSink"]
10
11
 
@@ -45,7 +46,7 @@ class DatadogSink(HTTPSink):
45
46
  site: str = "datadoghq.com",
46
47
  service: str | None = None,
47
48
  ddtags: str | None = None,
48
- **http_kwargs: object,
49
+ **http_kwargs: Unpack[HTTPPlatformKwargs],
49
50
  ) -> None:
50
51
  """Points the sink at a Datadog site's logs intake.
51
52
 
@@ -54,7 +55,9 @@ class DatadogSink(HTTPSink):
54
55
  site: The Datadog site, which selects the intake host.
55
56
  service: Overrides the event's own ``service``, or ``None`` to keep it.
56
57
  ddtags: Tags applied to every entry, or ``None`` for none.
57
- **http_kwargs: Forwarded to :class:`~log_foundry.sinks.http.HTTPSink`.
58
+ **http_kwargs: Forwarded to :class:`~log_foundry.sinks.http.HTTPSink`, typed as
59
+ ``HTTPPlatformKwargs`` (SPEC-051 FR-005) — every keyword it takes except
60
+ ``body_format``, which this sink pins to Datadog's JSON array.
58
61
 
59
62
  Returns:
60
63
  None.
@@ -67,7 +70,9 @@ class DatadogSink(HTTPSink):
67
70
  headers = merge_headers({"DD-API-KEY": api_key}, http_kwargs)
68
71
  super().__init__(
69
72
  f"https://http-intake.logs.{site}/api/v2/logs",
70
- headers=headers, body_format="json_array", **http_kwargs, # type: ignore[arg-type]
73
+ headers=headers,
74
+ body_format="json_array",
75
+ **http_kwargs, # type: ignore[misc]
71
76
  )
72
77
 
73
78
  def _render(self, event: dict[str, object]) -> str:
@@ -3,11 +3,12 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
+ from typing import Unpack
6
7
 
7
8
  from log_foundry import _diag
8
9
  from log_foundry.sinks._batch import usable_results
9
10
  from log_foundry.sinks.base import SinkLosses
10
- from log_foundry.sinks.http import HTTPSink, _Item
11
+ from log_foundry.sinks.http import HTTPRetryKwargs, HTTPSink, _Item
11
12
 
12
13
  __all__ = ["ElasticsearchSink", "OpenSearchSink"]
13
14
 
@@ -48,14 +49,17 @@ class ElasticsearchSink(HTTPSink):
48
49
  MAX_BATCH_BYTES = 10_000_000
49
50
 
50
51
  def __init__(self, url: str, *, index: str, auth: str | tuple[str, str] | None = None,
51
- **http_kwargs: object) -> None:
52
+ **http_kwargs: Unpack[HTTPRetryKwargs]) -> None:
52
53
  """Points the sink at a cluster's ``_bulk`` endpoint.
53
54
 
54
55
  Args:
55
56
  url: The cluster base URL, to which ``/_bulk`` is appended.
56
57
  index: The target index named in every action line.
57
58
  auth: A bearer token, or a ``(user, password)`` pair for basic auth.
58
- **http_kwargs: Forwarded to :class:`~log_foundry.sinks.http.HTTPSink`.
59
+ **http_kwargs: Forwarded to :class:`~log_foundry.sinks.http.HTTPSink`, typed as
60
+ ``HTTPRetryKwargs`` (SPEC-051 FR-005) — every keyword it takes except
61
+ ``body_format``, pinned to the bulk API's NDJSON, and ``auth``, which is a
62
+ parameter of this sink's own.
59
63
 
60
64
  Returns:
61
65
  None.
@@ -65,7 +69,7 @@ class ElasticsearchSink(HTTPSink):
65
69
  """
66
70
  self._index = index
67
71
  super().__init__(
68
- url.rstrip("/") + "/_bulk", auth=auth, body_format="ndjson", **http_kwargs # type: ignore[arg-type]
72
+ url.rstrip("/") + "/_bulk", auth=auth, body_format="ndjson", **http_kwargs
69
73
  )
70
74
  self.item_errors = 0
71
75
  self.dropped_unadjudicated = 0
@@ -186,9 +186,9 @@ class FilteringSink:
186
186
 
187
187
  Returns:
188
188
  The inner sink's losses. ``None`` passes through unchanged rather than becoming
189
- ``SinkLosses(0, 0)``: FR-003 distinguishes "the sink reports nothing" from "the sink
190
- reports no loss", and flattening the two would claim a clean bill of health on a sink
191
- that never gave one.
189
+ ``SinkLosses(dropped=0, failed=0)``: FR-003 distinguishes "the sink reports
190
+ nothing" from "the sink reports no loss", and flattening the two would claim a
191
+ clean bill of health on a sink that never gave one.
192
192
 
193
193
  Raises:
194
194
  None.
@@ -3,8 +3,9 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
+ from typing import Unpack
6
7
 
7
- from log_foundry.sinks.http import HTTPSink, merge_headers
8
+ from log_foundry.sinks.http import HTTPPlatformKwargs, HTTPSink, merge_headers
8
9
 
9
10
  __all__ = ["HoneycombSink"]
10
11
 
@@ -39,7 +40,7 @@ class HoneycombSink(HTTPSink):
39
40
  dataset: str,
40
41
  *,
41
42
  url: str = "https://api.honeycomb.io",
42
- **http_kwargs: object,
43
+ **http_kwargs: Unpack[HTTPPlatformKwargs],
43
44
  ) -> None:
44
45
  """Points the sink at a dataset's batch endpoint.
45
46
 
@@ -47,7 +48,9 @@ class HoneycombSink(HTTPSink):
47
48
  api_key: The key sent as ``X-Honeycomb-Team``.
48
49
  dataset: The target dataset, which forms part of the path.
49
50
  url: The API base URL.
50
- **http_kwargs: Forwarded to :class:`~log_foundry.sinks.http.HTTPSink`.
51
+ **http_kwargs: Forwarded to :class:`~log_foundry.sinks.http.HTTPSink`, typed as
52
+ ``HTTPPlatformKwargs`` (SPEC-051 FR-005) — every keyword it takes except
53
+ ``body_format``, which this sink pins to Honeycomb's JSON array.
51
54
 
52
55
  Returns:
53
56
  None.
@@ -58,7 +61,9 @@ class HoneycombSink(HTTPSink):
58
61
  headers = merge_headers({"X-Honeycomb-Team": api_key}, http_kwargs)
59
62
  super().__init__(
60
63
  f"{url.rstrip('/')}/1/batch/{dataset}",
61
- headers=headers, body_format="json_array", **http_kwargs, # type: ignore[arg-type]
64
+ headers=headers,
65
+ body_format="json_array",
66
+ **http_kwargs, # type: ignore[misc]
62
67
  )
63
68
 
64
69
  def _render(self, event: dict[str, object]) -> str:
@@ -8,17 +8,23 @@ import threading
8
8
  import urllib.error
9
9
  import urllib.request
10
10
  from base64 import b64encode
11
+ from collections.abc import Callable # noqa: TC003
11
12
  from dataclasses import dataclass
12
- from typing import TYPE_CHECKING, Any, NoReturn
13
+ from typing import Any, NoReturn, TypedDict
13
14
 
14
15
  from log_foundry import _diag
15
16
  from log_foundry.sinks._retry import clamp_server_delay, wait
16
17
  from log_foundry.sinks.base import SinkDeliveryError, SinkLosses
17
18
 
18
- if TYPE_CHECKING:
19
- from collections.abc import Callable
20
-
21
- __all__ = ["HTTPSink", "merge_headers"]
19
+ __all__ = [
20
+ "HTTPAuthKwargs",
21
+ "HTTPForwardKwargs",
22
+ "HTTPKwargs",
23
+ "HTTPPlatformKwargs",
24
+ "HTTPRetryKwargs",
25
+ "HTTPSink",
26
+ "merge_headers",
27
+ ]
22
28
 
23
29
  _BACKOFF_BASE = 0.1
24
30
 
@@ -196,7 +202,50 @@ def _no_redirect_opener() -> Callable[..., Any]:
196
202
  return urllib.request.build_opener(_NoRedirect()).open
197
203
 
198
204
 
199
- def merge_headers(base: dict[str, str], http_kwargs: dict[str, object]) -> dict[str, str]:
205
+ class HTTPForwardKwargs(TypedDict, total=False):
206
+ """The :class:`HTTPSink` keywords every platform sink in this family can forward.
207
+
208
+ Five shapes, composed by inheritance so each key is declared exactly once, replace the
209
+ ``**http_kwargs: object`` that let ``timeout="not-a-float"`` past ``mypy`` and past
210
+ construction (SPEC-051 FR-005). A platform sink cannot forward a keyword it sets itself or
211
+ shadows with a parameter of its own — ``mypy`` refuses a ``**kwargs`` TypedDict key that
212
+ collides with a named parameter — so this is the intersection and the four below widen it.
213
+ Every annotation matches ``HTTPSink.__init__``'s, and a roster test holds them there.
214
+ """
215
+
216
+ method: str
217
+ headers: dict[str, str] | None
218
+ gzip: bool
219
+ max_retry_after: float
220
+ max_batch_count: int | None
221
+ max_batch_bytes: int | None
222
+ opener: Callable[..., Any] | None
223
+
224
+
225
+ class HTTPRetryKwargs(HTTPForwardKwargs, total=False):
226
+ """:class:`HTTPForwardKwargs` plus the two a sink with a single transport does not own."""
227
+
228
+ timeout: float
229
+ max_retries: int
230
+
231
+
232
+ class HTTPAuthKwargs(HTTPForwardKwargs, total=False):
233
+ """:class:`HTTPForwardKwargs` plus ``auth``, for a sink taking no credentials of its own."""
234
+
235
+ auth: str | tuple[str, str] | None
236
+
237
+
238
+ class HTTPPlatformKwargs(HTTPRetryKwargs, HTTPAuthKwargs, total=False):
239
+ """Both of those: for a sink that owns only the body format."""
240
+
241
+
242
+ class HTTPKwargs(HTTPPlatformKwargs, total=False):
243
+ """Every keyword :class:`HTTPSink` accepts besides its positional ``url``."""
244
+
245
+ body_format: str
246
+
247
+
248
+ def merge_headers(base: dict[str, str], http_kwargs: HTTPForwardKwargs) -> dict[str, str]:
200
249
  """Merges a platform sink's own headers with any caller-supplied ones, caller winning.
201
250
 
202
251
  This is shared by the SaaS sinks that set a fixed auth header — Datadog, Splunk, New Relic,
@@ -205,7 +254,11 @@ def merge_headers(base: dict[str, str], http_kwargs: dict[str, object]) -> dict[
205
254
  Args:
206
255
  base: The sink's own headers, updated in place.
207
256
  http_kwargs: The caller's keyword arguments, mutated by popping ``headers`` out so the
208
- rest can be forwarded to :class:`HTTPSink` without a duplicate argument.
257
+ rest can be forwarded to :class:`HTTPSink` without a duplicate argument. Typed as the
258
+ narrowest shape (SPEC-051 FR-005); every wider one is assignable to it, and ``pop`` is
259
+ allowed because each key is ``total=False``. The pop is why a caller may still pass
260
+ ``headers``, and — since it happens *here*, in another function — why ``mypy`` reports
261
+ the caller's own forwarding call as a possible duplicate anyway.
209
262
 
210
263
  Returns:
211
264
  The merged headers.
@@ -3,7 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
- from typing import TYPE_CHECKING
6
+ from typing import TYPE_CHECKING, Unpack
7
7
 
8
8
  if TYPE_CHECKING:
9
9
  import threading
@@ -11,7 +11,7 @@ if TYPE_CHECKING:
11
11
  from log_foundry import _lifecycle
12
12
  from log_foundry.sinks._socket import DEFAULT_MAX_DATAGRAM_BYTES, SocketTransport
13
13
  from log_foundry.sinks.base import SinkLosses
14
- from log_foundry.sinks.http import HTTPSink
14
+ from log_foundry.sinks.http import HTTPAuthKwargs, HTTPSink
15
15
 
16
16
  __all__ = ["LogstashSink"]
17
17
 
@@ -90,7 +90,7 @@ class LogstashSink:
90
90
  timeout: float = 5.0,
91
91
  max_retries: int = 3,
92
92
  max_datagram_bytes: int = DEFAULT_MAX_DATAGRAM_BYTES,
93
- **http_kwargs: object,
93
+ **http_kwargs: Unpack[HTTPAuthKwargs],
94
94
  ) -> None:
95
95
  """Selects and builds exactly one backend.
96
96
 
@@ -110,7 +110,11 @@ class LogstashSink:
110
110
  max_datagram_bytes: In UDP socket mode, the largest datagram to attempt; a frame over
111
111
  it is dropped and counted rather than sent, retried and abandoned (SPEC-038 FR-007).
112
112
  Ignored in HTTP mode and over TCP, which is a stream.
113
- **http_kwargs: Forwarded to :class:`~log_foundry.sinks.http.HTTPSink` in HTTP mode.
113
+ **http_kwargs: Forwarded to :class:`~log_foundry.sinks.http.HTTPSink` in HTTP mode,
114
+ typed as ``HTTPAuthKwargs`` (SPEC-051 FR-005) — every keyword it takes except
115
+ ``body_format``, ``timeout`` and ``max_retries``, each a parameter of this sink's
116
+ own: the last two because socket mode uses them, and ``body_format`` because it is
117
+ validated in both modes and defaults differently here.
114
118
 
115
119
  Returns:
116
120
  None.
@@ -127,7 +131,7 @@ class LogstashSink:
127
131
  if url is not None:
128
132
  self._http: HTTPSink | None = HTTPSink(
129
133
  url, body_format=body_format, timeout=timeout, max_retries=max_retries,
130
- **http_kwargs, # type: ignore[arg-type]
134
+ **http_kwargs,
131
135
  )
132
136
  self._socket: SocketTransport | None = None
133
137
  elif host is not None and port is not None:
@@ -3,9 +3,10 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
+ from typing import Unpack
6
7
 
7
8
  from log_foundry.sinks._time import epoch_nanos
8
- from log_foundry.sinks.http import HTTPSink, _Item
9
+ from log_foundry.sinks.http import HTTPPlatformKwargs, HTTPSink, _Item
9
10
 
10
11
  __all__ = ["LokiSink"]
11
12
 
@@ -48,13 +49,15 @@ class LokiSink(HTTPSink):
48
49
  MAX_BATCH_BYTES = 4_000_000
49
50
 
50
51
  def __init__(self, url: str, *, labels: tuple[str, ...] = ("service", "env", "level"),
51
- **http_kwargs: object) -> None:
52
+ **http_kwargs: Unpack[HTTPPlatformKwargs]) -> None:
52
53
  """Points the sink at a Loki push endpoint.
53
54
 
54
55
  Args:
55
56
  url: The Loki base URL, to which the push path is appended.
56
57
  labels: The event keys promoted to stream labels.
57
- **http_kwargs: Forwarded to :class:`~log_foundry.sinks.http.HTTPSink`.
58
+ **http_kwargs: Forwarded to :class:`~log_foundry.sinks.http.HTTPSink`, typed as
59
+ ``HTTPPlatformKwargs`` (SPEC-051 FR-005) — every keyword it takes except
60
+ ``body_format``, which this sink pins to Loki's JSON array.
58
61
 
59
62
  Returns:
60
63
  None.
@@ -64,7 +67,7 @@ class LokiSink(HTTPSink):
64
67
  """
65
68
  self._labels = labels
66
69
  super().__init__(
67
- url.rstrip("/") + _PUSH_PATH, body_format="json_array", **http_kwargs # type: ignore[arg-type]
70
+ url.rstrip("/") + _PUSH_PATH, body_format="json_array", **http_kwargs
68
71
  )
69
72
 
70
73
  def _render(self, event: dict[str, object]) -> str:
@@ -2,7 +2,9 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from log_foundry.sinks.http import HTTPSink, merge_headers
5
+ from typing import Unpack
6
+
7
+ from log_foundry.sinks.http import HTTPPlatformKwargs, HTTPSink, merge_headers
6
8
 
7
9
  __all__ = ["NewRelicSink"]
8
10
 
@@ -29,13 +31,18 @@ class NewRelicSink(HTTPSink):
29
31
  MAX_BATCH_COUNT = 1000
30
32
  MAX_BATCH_BYTES = 1_000_000
31
33
 
32
- def __init__(self, api_key: str, *, region: str = "US", **http_kwargs: object) -> None:
34
+ def __init__(
35
+ self, api_key: str, *, region: str = "US",
36
+ **http_kwargs: Unpack[HTTPPlatformKwargs],
37
+ ) -> None:
33
38
  """Points the sink at a region's Log API endpoint.
34
39
 
35
40
  Args:
36
41
  api_key: The key sent as ``Api-Key``.
37
42
  region: The account region, matched case-insensitively, which selects the host.
38
- **http_kwargs: Forwarded to :class:`~log_foundry.sinks.http.HTTPSink`.
43
+ **http_kwargs: Forwarded to :class:`~log_foundry.sinks.http.HTTPSink`, typed as
44
+ ``HTTPPlatformKwargs`` (SPEC-051 FR-005) — every keyword it takes except
45
+ ``body_format``, which this sink pins to New Relic's JSON array.
39
46
 
40
47
  Returns:
41
48
  None.
@@ -49,5 +56,7 @@ class NewRelicSink(HTTPSink):
49
56
  headers = merge_headers({"Api-Key": api_key}, http_kwargs)
50
57
  super().__init__(
51
58
  f"https://{_HOSTS[region]}/log/v1",
52
- headers=headers, body_format="json_array", **http_kwargs, # type: ignore[arg-type]
59
+ headers=headers,
60
+ body_format="json_array",
61
+ **http_kwargs, # type: ignore[misc]
53
62
  )
@@ -12,10 +12,14 @@ from log_foundry import _diag, _lifecycle
12
12
  from log_foundry.sinks.base import SinkDeliveryError, SinkLosses
13
13
  from log_foundry.sinks.http import HTTPSink
14
14
 
15
- __all__ = ["SentrySink"]
15
+ __all__ = ["Backend", "SentrySink"]
16
16
 
17
17
  Backend = Literal["auto", "sdk", "http"]
18
- """Which transport a :class:`SentrySink` uses. Not exported: callers pass the literals."""
18
+ """Which transport a :class:`SentrySink` uses.
19
+
20
+ Exported (SPEC-051 FR-004) because it is in a public signature: passing the literal is still
21
+ the ordinary way to call, but a caller annotating a variable that holds one needs the name.
22
+ """
19
23
 
20
24
  _BACKENDS: Final = get_args(Backend)
21
25
 
@@ -3,9 +3,10 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
+ from typing import Unpack
6
7
 
7
8
  from log_foundry.sinks._time import epoch_seconds
8
- from log_foundry.sinks.http import HTTPSink, _Item, merge_headers
9
+ from log_foundry.sinks.http import HTTPKwargs, HTTPSink, _Item, merge_headers
9
10
 
10
11
  __all__ = ["SplunkHECSink"]
11
12
 
@@ -44,7 +45,7 @@ class SplunkHECSink(HTTPSink):
44
45
  *,
45
46
  host: str | None = None,
46
47
  source: str = "log-foundry",
47
- **http_kwargs: object,
48
+ **http_kwargs: Unpack[HTTPKwargs],
48
49
  ) -> None:
49
50
  """Points the sink at a collector endpoint.
50
51
 
@@ -53,7 +54,9 @@ class SplunkHECSink(HTTPSink):
53
54
  token: The HEC token sent in the ``Authorization`` header.
54
55
  host: The ``host`` stamped on each envelope, or ``None`` to omit it.
55
56
  source: The ``source`` stamped on each envelope.
56
- **http_kwargs: Forwarded to :class:`~log_foundry.sinks.http.HTTPSink`.
57
+ **http_kwargs: Forwarded to :class:`~log_foundry.sinks.http.HTTPSink`, typed as
58
+ ``HTTPKwargs`` (SPEC-051 FR-005) — every keyword it takes, since this sink pins
59
+ none of them.
57
60
 
58
61
  Returns:
59
62
  None.
@@ -64,7 +67,7 @@ class SplunkHECSink(HTTPSink):
64
67
  self._host = host
65
68
  self._source = source
66
69
  headers = merge_headers({"Authorization": f"Splunk {token}"}, http_kwargs)
67
- super().__init__(url, headers=headers, **http_kwargs) # type: ignore[arg-type]
70
+ super().__init__(url, headers=headers, **http_kwargs) # type: ignore[misc]
68
71
 
69
72
  def _render(self, event: dict[str, object]) -> str:
70
73
  """Serializes one event's HEC envelope (FR-008).
@@ -16,7 +16,7 @@ from log_foundry import _diag
16
16
  from log_foundry.sinks._retry import wait
17
17
  from log_foundry.sinks.base import SinkDeliveryError, SinkLosses
18
18
 
19
- __all__ = ["SQSSink"]
19
+ __all__ = ["DedupIdSource", "GroupIdSource", "SQSSink"]
20
20
 
21
21
  _BACKOFF_BASE = 0.1
22
22
 
@@ -27,7 +27,10 @@ MAX_ID_LEN = 128
27
27
  """SQS maximum length for ``MessageGroupId`` and ``MessageDeduplicationId``."""
28
28
 
29
29
  GroupIdSource = str | Callable[[dict[str, object]], str] | None
30
+ """What ``message_group_id=`` accepts: an event key to read, a callable, or ``None``."""
31
+
30
32
  DedupIdSource = Callable[[dict[str, object]], str] | None
33
+ """What ``message_deduplication_id=`` accepts: a callable, or ``None`` for a minted UUID."""
31
34
 
32
35
 
33
36
  class _Prepared(NamedTuple):
@@ -154,9 +154,9 @@ class TransformSink:
154
154
 
155
155
  Returns:
156
156
  The inner sink's losses. ``None`` passes through unchanged rather than becoming
157
- ``SinkLosses(0, 0)``: FR-003 distinguishes "the sink reports nothing" from "the sink
158
- reports no loss", and flattening the two would claim a clean bill of health on a sink
159
- that never gave one.
157
+ ``SinkLosses(dropped=0, failed=0)``: FR-003 distinguishes "the sink reports
158
+ nothing" from "the sink reports no loss", and flattening the two would claim a
159
+ clean bill of health on a sink that never gave one.
160
160
 
161
161
  Raises:
162
162
  None.
@@ -83,14 +83,17 @@ def _bounded_seconds(timeout: float | None) -> str:
83
83
  return "?"
84
84
 
85
85
 
86
- @dataclass(frozen=True)
86
+ @dataclass(frozen=True, kw_only=True)
87
87
  class Health:
88
88
  """A point-in-time snapshot of the worker's delivery counters (SPEC-017 FR-005).
89
89
 
90
- ``stopped_reason``, ``sink``, and SPEC-030's three are defaulted and appended in that
91
- order, so the zeroed snapshot in ``_lifecycle._worker_health`` — and any third-party
92
- construction — keeps working, and attribute and index access to every earlier field stays
93
- as it was.
90
+ Construction is **keyword-only** (SPEC-051 FR-001), which is what makes appending a field
91
+ safe: order is not part of the contract, so a new counter can go anywhere and no third-party
92
+ construction can bind to a position. It was previously ordered — every field after
93
+ ``failed_batches`` is defaulted and was appended in the order the specs landed. The claim
94
+ that a caller could also subscript this, carried here from the ``NamedTuple`` SPEC-034
95
+ replaced, was false from the moment it became a dataclass: ``len(health())`` raises
96
+ ``TypeError``, and ``README.md`` said the opposite correctly throughout.
94
97
 
95
98
  Attributes:
96
99
  queued: Submissions currently buffered. Approximate by nature: it is read without
@@ -148,8 +151,10 @@ class Health:
148
151
  closing_sinks: Swapped-out sinks whose ``close()`` is running *at this instant* — a live
149
152
  gauge rather than a counter, so it falls as well as rises. ~~the only field here that
150
153
  can fall~~ — struck (SPEC-034 AC-2c): ``queued`` falls on every drain. Those two are the
151
- gauges and the other five integers are monotonic, which is the distinction an operator
152
- alerting on "any non-zero" needs and which no name here encodes. A close is
154
+ only gauges and every other integer here is monotonic, which is the distinction an
155
+ operator alerting on "any non-zero" needs and which no name encodes. Stated as the rule
156
+ rather than as a count: it said "the other five" until SPEC-051, having gone stale when
157
+ SPEC-036 appended ``orphan_lost`` and ``in_span_lost`` and made it six. A close is
153
158
  bounded only in how long ``configure()`` waits for it, so this is how a destination
154
159
  stuck in ``close()`` becomes visible at all. Reading it non-zero once means a swap just
155
160
  happened; reading it non-zero repeatedly means a close is not coming back, and that sink