log-foundry 0.10.2.dev68__tar.gz → 0.10.2.dev69__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.dev68 → log_foundry-0.10.2.dev69}/PKG-INFO +10 -1
  2. {log_foundry-0.10.2.dev68 → log_foundry-0.10.2.dev69}/README.md +9 -0
  3. {log_foundry-0.10.2.dev68 → log_foundry-0.10.2.dev69}/pyproject.toml +1 -1
  4. {log_foundry-0.10.2.dev68 → log_foundry-0.10.2.dev69}/src/log_foundry/context.py +21 -0
  5. {log_foundry-0.10.2.dev68 → log_foundry-0.10.2.dev69}/src/log_foundry/decorator.py +158 -8
  6. {log_foundry-0.10.2.dev68 → log_foundry-0.10.2.dev69}/src/log_foundry/model.py +13 -0
  7. {log_foundry-0.10.2.dev68 → log_foundry-0.10.2.dev69}/LICENSE +0 -0
  8. {log_foundry-0.10.2.dev68 → log_foundry-0.10.2.dev69}/src/log_foundry/__init__.py +0 -0
  9. {log_foundry-0.10.2.dev68 → log_foundry-0.10.2.dev69}/src/log_foundry/_diag.py +0 -0
  10. {log_foundry-0.10.2.dev68 → log_foundry-0.10.2.dev69}/src/log_foundry/_fork.py +0 -0
  11. {log_foundry-0.10.2.dev68 → log_foundry-0.10.2.dev69}/src/log_foundry/_lifecycle.py +0 -0
  12. {log_foundry-0.10.2.dev68 → log_foundry-0.10.2.dev69}/src/log_foundry/api.py +0 -0
  13. {log_foundry-0.10.2.dev68 → log_foundry-0.10.2.dev69}/src/log_foundry/config.py +0 -0
  14. {log_foundry-0.10.2.dev68 → log_foundry-0.10.2.dev69}/src/log_foundry/console.py +0 -0
  15. {log_foundry-0.10.2.dev68 → log_foundry-0.10.2.dev69}/src/log_foundry/ids.py +0 -0
  16. {log_foundry-0.10.2.dev68 → log_foundry-0.10.2.dev69}/src/log_foundry/py.typed +0 -0
  17. {log_foundry-0.10.2.dev68 → log_foundry-0.10.2.dev69}/src/log_foundry/results.py +0 -0
  18. {log_foundry-0.10.2.dev68 → log_foundry-0.10.2.dev69}/src/log_foundry/sanitize.py +0 -0
  19. {log_foundry-0.10.2.dev68 → log_foundry-0.10.2.dev69}/src/log_foundry/sinks/__init__.py +0 -0
  20. {log_foundry-0.10.2.dev68 → log_foundry-0.10.2.dev69}/src/log_foundry/sinks/_batch.py +0 -0
  21. {log_foundry-0.10.2.dev68 → log_foundry-0.10.2.dev69}/src/log_foundry/sinks/_chunk.py +0 -0
  22. {log_foundry-0.10.2.dev68 → log_foundry-0.10.2.dev69}/src/log_foundry/sinks/_retry.py +0 -0
  23. {log_foundry-0.10.2.dev68 → log_foundry-0.10.2.dev69}/src/log_foundry/sinks/_socket.py +0 -0
  24. {log_foundry-0.10.2.dev68 → log_foundry-0.10.2.dev69}/src/log_foundry/sinks/_time.py +0 -0
  25. {log_foundry-0.10.2.dev68 → log_foundry-0.10.2.dev69}/src/log_foundry/sinks/base.py +0 -0
  26. {log_foundry-0.10.2.dev68 → log_foundry-0.10.2.dev69}/src/log_foundry/sinks/callback.py +0 -0
  27. {log_foundry-0.10.2.dev68 → log_foundry-0.10.2.dev69}/src/log_foundry/sinks/clickhouse.py +0 -0
  28. {log_foundry-0.10.2.dev68 → log_foundry-0.10.2.dev69}/src/log_foundry/sinks/datadog.py +0 -0
  29. {log_foundry-0.10.2.dev68 → log_foundry-0.10.2.dev69}/src/log_foundry/sinks/elasticsearch.py +0 -0
  30. {log_foundry-0.10.2.dev68 → log_foundry-0.10.2.dev69}/src/log_foundry/sinks/eventhubs.py +0 -0
  31. {log_foundry-0.10.2.dev68 → log_foundry-0.10.2.dev69}/src/log_foundry/sinks/file.py +0 -0
  32. {log_foundry-0.10.2.dev68 → log_foundry-0.10.2.dev69}/src/log_foundry/sinks/filtering.py +0 -0
  33. {log_foundry-0.10.2.dev68 → log_foundry-0.10.2.dev69}/src/log_foundry/sinks/firehose.py +0 -0
  34. {log_foundry-0.10.2.dev68 → log_foundry-0.10.2.dev69}/src/log_foundry/sinks/honeycomb.py +0 -0
  35. {log_foundry-0.10.2.dev68 → log_foundry-0.10.2.dev69}/src/log_foundry/sinks/http.py +0 -0
  36. {log_foundry-0.10.2.dev68 → log_foundry-0.10.2.dev69}/src/log_foundry/sinks/kafka.py +0 -0
  37. {log_foundry-0.10.2.dev68 → log_foundry-0.10.2.dev69}/src/log_foundry/sinks/kinesis.py +0 -0
  38. {log_foundry-0.10.2.dev68 → log_foundry-0.10.2.dev69}/src/log_foundry/sinks/logging_sink.py +0 -0
  39. {log_foundry-0.10.2.dev68 → log_foundry-0.10.2.dev69}/src/log_foundry/sinks/logstash.py +0 -0
  40. {log_foundry-0.10.2.dev68 → log_foundry-0.10.2.dev69}/src/log_foundry/sinks/loki.py +0 -0
  41. {log_foundry-0.10.2.dev68 → log_foundry-0.10.2.dev69}/src/log_foundry/sinks/memory.py +0 -0
  42. {log_foundry-0.10.2.dev68 → log_foundry-0.10.2.dev69}/src/log_foundry/sinks/mongodb.py +0 -0
  43. {log_foundry-0.10.2.dev68 → log_foundry-0.10.2.dev69}/src/log_foundry/sinks/multi.py +0 -0
  44. {log_foundry-0.10.2.dev68 → log_foundry-0.10.2.dev69}/src/log_foundry/sinks/nats.py +0 -0
  45. {log_foundry-0.10.2.dev68 → log_foundry-0.10.2.dev69}/src/log_foundry/sinks/newrelic.py +0 -0
  46. {log_foundry-0.10.2.dev68 → log_foundry-0.10.2.dev69}/src/log_foundry/sinks/null.py +0 -0
  47. {log_foundry-0.10.2.dev68 → log_foundry-0.10.2.dev69}/src/log_foundry/sinks/postgres.py +0 -0
  48. {log_foundry-0.10.2.dev68 → log_foundry-0.10.2.dev69}/src/log_foundry/sinks/pubsub.py +0 -0
  49. {log_foundry-0.10.2.dev68 → log_foundry-0.10.2.dev69}/src/log_foundry/sinks/rabbitmq.py +0 -0
  50. {log_foundry-0.10.2.dev68 → log_foundry-0.10.2.dev69}/src/log_foundry/sinks/redis.py +0 -0
  51. {log_foundry-0.10.2.dev68 → log_foundry-0.10.2.dev69}/src/log_foundry/sinks/sentry.py +0 -0
  52. {log_foundry-0.10.2.dev68 → log_foundry-0.10.2.dev69}/src/log_foundry/sinks/sns.py +0 -0
  53. {log_foundry-0.10.2.dev68 → log_foundry-0.10.2.dev69}/src/log_foundry/sinks/splunk.py +0 -0
  54. {log_foundry-0.10.2.dev68 → log_foundry-0.10.2.dev69}/src/log_foundry/sinks/sqlite.py +0 -0
  55. {log_foundry-0.10.2.dev68 → log_foundry-0.10.2.dev69}/src/log_foundry/sinks/sqs.py +0 -0
  56. {log_foundry-0.10.2.dev68 → log_foundry-0.10.2.dev69}/src/log_foundry/sinks/stdout.py +0 -0
  57. {log_foundry-0.10.2.dev68 → log_foundry-0.10.2.dev69}/src/log_foundry/sinks/syslog.py +0 -0
  58. {log_foundry-0.10.2.dev68 → log_foundry-0.10.2.dev69}/src/log_foundry/sinks/transform.py +0 -0
  59. {log_foundry-0.10.2.dev68 → log_foundry-0.10.2.dev69}/src/log_foundry/worker.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: log-foundry
3
- Version: 0.10.2.dev68
3
+ Version: 0.10.2.dev69
4
4
  Summary: Generate logs for your console and JSON events for downstream consumption.
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -233,6 +233,15 @@ is FIFO, everything submitted before the call is necessarily ahead of that marke
233
233
  exactly why the guarantee is "events submitted before this call", and why concurrent
234
234
  submissions from other threads may or may not be included.
235
235
 
236
+ **`flush()` also sweeps the spans that are still open**, so an in-span event does not have to wait
237
+ for its span to close to be delivered. The span stays open and usable afterwards: its events go
238
+ now and its `span.end` arrives later, in its own batch. Two consequences worth knowing. Boundary
239
+ events swept this way carry the baggage as of the **flush** rather than as of the close, since
240
+ that completion has to happen before they leave. And the sweep reaches only the **calling
241
+ context's** spans — `contextvars` offers no way to enumerate another thread's or task's context,
242
+ so a `flush()` in a handler that fanned out to tasks does not reach what those tasks have
243
+ buffered; their events arrive when their own spans close.
244
+
236
245
  ## Usage
237
246
 
238
247
  ### `configure(...)`
@@ -197,6 +197,15 @@ is FIFO, everything submitted before the call is necessarily ahead of that marke
197
197
  exactly why the guarantee is "events submitted before this call", and why concurrent
198
198
  submissions from other threads may or may not be included.
199
199
 
200
+ **`flush()` also sweeps the spans that are still open**, so an in-span event does not have to wait
201
+ for its span to close to be delivered. The span stays open and usable afterwards: its events go
202
+ now and its `span.end` arrives later, in its own batch. Two consequences worth knowing. Boundary
203
+ events swept this way carry the baggage as of the **flush** rather than as of the close, since
204
+ that completion has to happen before they leave. And the sweep reaches only the **calling
205
+ context's** spans — `contextvars` offers no way to enumerate another thread's or task's context,
206
+ so a `flush()` in a handler that fanned out to tasks does not reach what those tasks have
207
+ buffered; their events arrive when their own spans close.
208
+
200
209
  ## Usage
201
210
 
202
211
  ### `configure(...)`
@@ -20,7 +20,7 @@ dependencies = [
20
20
  ]
21
21
 
22
22
  # Optional features. Install with: pip install log-foundry[aws]
23
- version = "0.10.2.dev68"
23
+ version = "0.10.2.dev69"
24
24
 
25
25
  [project.optional-dependencies]
26
26
  aws = ["boto3>=1.43.61"] # SQSSink, SNSSink, KinesisSink, FirehoseSink
@@ -55,6 +55,27 @@ def current_span() -> Span | None:
55
55
  return stack[-1] if stack else None
56
56
 
57
57
 
58
+ def _live_span_stack() -> tuple[Span, ...]:
59
+ """Returns every span open on this context, outermost first (SPEC-036 FR-001).
60
+
61
+ The library's non-copying read, named as :func:`_live_baggage` is and distinguished from the
62
+ public accessors for the same reason (SPEC-034 FR-003): a caller gets a copy, the library
63
+ reads the live object. There is nothing to copy here in any case — the stack is a tuple, so
64
+ handing it out cannot let anyone mutate it. :func:`current_span` returns only the innermost,
65
+ which is what an event needs and not what a sweep does.
66
+
67
+ Args:
68
+ None.
69
+
70
+ Returns:
71
+ Every span open on this context, outermost first. Empty when none is.
72
+
73
+ Raises:
74
+ None.
75
+ """
76
+ return _span_stack.get()
77
+
78
+
58
79
  def push_span(span: Span) -> contextvars.Token[tuple[Span, ...]]:
59
80
  """Pushes a span onto the stack.
60
81
 
@@ -60,6 +60,13 @@ shutdown's event has every backoff collapsed to zero.
60
60
  """
61
61
  _orphan_retired = False
62
62
 
63
+ _sweep_lock = threading.Lock()
64
+ """Serializes the span sweep, so two threads cannot deliver one span's buffer twice.
65
+
66
+ The detach is a load and a store, and ``contextvars`` copies the same ``Span`` object into every
67
+ task and into any ``copy_context()`` thread, so the two-reader case is ordinary rather than exotic
68
+ (SPEC-036 FR-001 AC-10). A flush is not a hot path; a sink lock it is not competing with.
69
+ """
63
70
  _loss_lock = threading.Lock()
64
71
  """Guards the two loss counters, and deliberately not ``_worker_lock`` (SPEC-036 FR-003 AC-5).
65
72
 
@@ -197,6 +204,14 @@ def continue_trace(
197
204
  _diag.rejected("parent_span_id given with no trace_id to join", parent_span_id)
198
205
  announced = True
199
206
 
207
+ if adopted is not None and _current_span_was_swept():
208
+ _diag.rejected(
209
+ "the current span has already been flushed; trace context refused",
210
+ traceparent if traceparent is not None else str(trace_id),
211
+ )
212
+ adopted = None
213
+ announced = True
214
+
200
215
  if adopted is not None:
201
216
  context.set_adopted_context(*adopted)
202
217
  _reparent_current_span(*adopted)
@@ -214,6 +229,32 @@ def continue_trace(
214
229
  return ContinueResult(ok=False, reason="rejected" if announced else "nothing-supplied")
215
230
 
216
231
 
232
+ def _current_span_was_swept() -> bool:
233
+ """Reports whether an in-span ``flush()`` has already shipped this span's events.
234
+
235
+ Read from ``context.current_span()`` — what :func:`_reparent_current_span` itself reads —
236
+ **and only when that span is a root**, which is the other half of that function's own guard.
237
+ A swept *child* is not a reason to refuse: the re-parent would have returned early on it and
238
+ rewritten nothing, so a refusal there prevents no corruption. It would still be wrong to
239
+ refuse — the two guards must agree, or the refusal fires where the thing it guards does not
240
+ run — though the adoption it spares reaches less than it appears to: SPEC-024 clears the
241
+ adopted context at the **root** span's close, so one made inside a child does not survive to
242
+ the next root span either. ``continue_trace``'s documented placement on the entry
243
+ point's first line is untouched either way: nothing has been swept that early.
244
+
245
+ Args:
246
+ None.
247
+
248
+ Returns:
249
+ Whether the current span is a root that has been swept.
250
+
251
+ Raises:
252
+ None.
253
+ """
254
+ span = context.current_span()
255
+ return span is not None and span.parent_span_id is None and span.swept
256
+
257
+
217
258
  def _reparent_current_span(trace_id: str, parent_span_id: str | None) -> None:
218
259
  """Moves an already-open root span into the adopted trace, events included.
219
260
 
@@ -712,33 +753,133 @@ def _adopt_declined_swap(new_sink: Sink) -> None:
712
753
  _orphan_sink = new_sink
713
754
 
714
755
 
756
+ def _sweep_open_spans() -> None:
757
+ """Hands the worker every event buffered on an open span in this context (SPEC-036 FR-001).
758
+
759
+ An in-span event lives on ``span.events`` until the span *closes*, and ``Worker.flush``
760
+ drains the *queue* — so a ``flush()`` called inside a ``@trace``d function, which is where
761
+ the README's serverless recipe put it, had by construction nothing to drain. Measured: zero
762
+ of two events delivered, every counter clean, and ``FlushResult`` reporting ``reason=None``.
763
+
764
+ The span stays **open**: its events go now and its ``span.end`` arrives later in its own
765
+ batch. Closing and reopening was rejected — it would emit a ``span.end`` the function never
766
+ reached, with a fabricated ``duration_ms`` and ``status``.
767
+
768
+ Two things must happen before the events leave, and both are why this is not a one-liner.
769
+ The boundary events are backfilled **first**, because SPEC-015 completes them at close by
770
+ iterating ``span.events`` and a swept buffer would ship ``span.start`` with ``fields={}`` —
771
+ the very defect that spec exists to fix, recreated by any in-span flush. They therefore carry
772
+ the baggage as of the flush rather than as of the close, which is a real semantic change and
773
+ the alternative is mutating an event the worker already owns (SPEC-028). And the buffer is
774
+ **detached by swap**, never cleared: ``clear()`` empties the same list object the worker was
775
+ handed.
776
+
777
+ The worker is created when there is something to submit, and **resolved before the buffer is
778
+ detached**. That ordering is the whole of the difference between a lost batch and a delivered
779
+ one: ``_get_worker`` can raise — it ends in ``Thread.start()`` — and a detach that has already
780
+ happened leaves the events in a discarded local while the span reads empty and ``flush()``
781
+ reports success. Measured with the failure injected: 3 of 4 events destroyed, every counter
782
+ zero, on a span that was still open and would have delivered them at its close.
783
+ ``Worker.submit`` raises nothing, so once it is reached the batch is safe. Creating the worker
784
+ at all narrows SPEC-013's refusal rather than contradicting it — that exists so an *empty*
785
+ flush does not stand up a thread, and a sweep that found buffered events is not an empty
786
+ flush. A cold-start Lambda flushing before it returns is exactly this case: the worker is
787
+ built when the first span *closes*, so inside the first traced call there is none.
788
+
789
+ Concurrent sweeps are serialized on ``_sweep_lock``. The detach is a load and a store with a
790
+ real gap between them, and two threads sharing one ``Span`` — which ``contextvars`` makes
791
+ ordinary — can both read the same buffer and deliver it twice: measured, all 8 events
792
+ duplicated with the window held open, and 9 of 25 runs with only a GIL yield between them.
793
+ Rarely preempted on today's build is not a guarantee, and the floor is ``>=3.12`` where a
794
+ free-threading build removes even that. A flush is not a hot path, so a single lock is the
795
+ right cost.
796
+
797
+ **The detach stays one statement, and the two orderings above are not in tension.** A draft
798
+ hoisted the load to the top of the loop so a test could park on it — which put
799
+ ``_get_worker()``, and therefore ``Thread.start()``, *inside* the load-to-store gap: measured,
800
+ a sweep racing a close then delivered the whole batch twice, two ``span.end`` events among
801
+ them, in 67 of 100 unforced trials against 0 before.
802
+
803
+ One statement makes that gap **narrow, not closed**, and the difference matters. It compiles
804
+ to ``LOAD_ATTR … STORE_ATTR`` with no ``CALL`` between, so CPython's eval breaker never runs
805
+ there and today's GIL cannot switch inside it — 0 of 500 unforced trials. Forced with an
806
+ opcode-level preemption it reproduces 10 of 10, and a free-threaded build removes the
807
+ accident entirely while ``requires-python`` has no upper bound. So :func:`_flush` takes this
808
+ same lock rather than relying on the width of a window: that is the *detach-vs-detach* race,
809
+ and a process-global lock is the right instrument for it. The **append** race
810
+ (``api._log`` versus a detach) is a different window needing a per-span lock, and
811
+ ``architecture.md`` §13 declines it on cost.
812
+
813
+ It reaches only the calling context's spans. ``contextvars`` offers no way to enumerate
814
+ another thread's or task's context, so a ``flush()`` in a handler that fanned out does not
815
+ reach what those tasks buffered.
816
+
817
+ Args:
818
+ None.
819
+
820
+ Returns:
821
+ None.
822
+
823
+ Raises:
824
+ Exception: Whatever building the worker raises. :func:`_flush_worker` guards it, because a
825
+ flush is the call most likely to be made in a ``finally``.
826
+ """
827
+ with _sweep_lock:
828
+ for span in context._live_span_stack():
829
+ if not span.events:
830
+ span.swept = True
831
+ continue
832
+ worker = _get_worker()
833
+ backfill_baggage(span, context._live_baggage())
834
+ span.swept = True
835
+ buffered, span.events = span.events, []
836
+ worker.submit(buffered)
837
+
838
+
715
839
  def _flush_worker(timeout: float | None = 5.0) -> FlushResult:
716
840
  """Drains the process worker without retiring it, backing ``flush()`` (SPEC-013 FR-003).
717
841
 
718
- This deliberately does not call :func:`_get_worker`: a process that never logged has
719
- nothing to drain, and building a worker — with the thread and ``atexit`` registration
720
- that brings — in order to flush nothing would be pure cost.
842
+ ~~This deliberately does not call :func:`_get_worker`~~ — narrowed by SPEC-036 FR-001. The
843
+ refusal still holds for an *empty* flush: a process that never logged has nothing to drain,
844
+ and building a worker — with the thread and ``atexit`` registration that brings — in order to
845
+ flush nothing would be pure cost. What changed is that :func:`_sweep_open_spans`, which runs
846
+ first, does build one when it finds buffered events on an open span, because submitting them
847
+ into a worker that does not exist delivers nothing and still reports success.
721
848
 
722
849
  Args:
723
850
  timeout: Seconds to wait for the drain, or ``None`` to wait indefinitely.
724
851
 
725
852
  Returns:
726
853
  A :class:`FlushResult`, truthy when everything outstanding was delivered and when no
727
- worker exists — a process that never logged has nothing to drain, so it has lost
728
- nothing.
854
+ worker exists — a process that never logged has nothing to drain, so it has lost nothing.
855
+ A sweep that could not hand its buffers over reports ``"abandoned"``, the existing token
856
+ for "this call did not deliver them" (SPEC-036 FR-001): the events are still on their open
857
+ spans and their close may yet carry them, but the caller asked *now*, and on the
858
+ cold-start path this exists for there may be no close — reporting success there is the
859
+ exact shape the spec was written to remove. The drain still runs, so whatever was
860
+ submitted before the failure is not held back by it.
729
861
 
730
862
  Raises:
731
863
  None. A flush is the call most likely to be made in a ``finally``, so the library must
732
864
  never be the reason a caller's function fails; a failure is reported by the return
733
865
  value instead (FR-003).
734
866
  """
867
+ swept = True
868
+ try:
869
+ _sweep_open_spans()
870
+ except Exception as exc:
871
+ _diag.absorbed("sweeping open spans for a flush", exc, "buffered events were not swept")
872
+ swept = False
735
873
  worker = _worker
736
874
  if worker is None:
737
- return FlushResult(ok=True)
875
+ return FlushResult(ok=True) if swept else FlushResult(ok=False, reason="abandoned")
738
876
  try:
739
- return worker.flush(timeout)
877
+ if swept:
878
+ return worker.flush(timeout)
879
+ worker.flush(timeout)
740
880
  except Exception:
741
881
  return FlushResult(ok=False, reason="thread-died")
882
+ return FlushResult(ok=False, reason="abandoned")
742
883
 
743
884
 
744
885
  def _note_orphan_loss() -> None:
@@ -919,6 +1060,14 @@ def _flush(span: Span) -> None:
919
1060
  The late append is now landing in a buffer nothing will emit, which is *also* loss — that
920
1061
  half is ``api._log``'s, keyed on :attr:`Span.closed`.
921
1062
 
1063
+ It takes ``_sweep_lock`` for the detach, because :func:`_sweep_open_spans` performs the same
1064
+ detach on the same attribute and a span can be swept and closed concurrently — measured, the
1065
+ whole batch delivered twice with two ``span.end`` events among them. The hold covers one
1066
+ statement and not the submit; ``Worker.submit`` is a ``put_nowait`` that never blocks, and the
1067
+ cost of the lock on the traced path measured within noise (+0.4% single-threaded, +0.9% across
1068
+ eight threads, 20,000 spans each). The **append** window this does not close is a different
1069
+ one, needs a per-span lock, and is declined in ``architecture.md`` §13.
1070
+
922
1071
  Args:
923
1072
  span: The finished span whose buffered events are submitted.
924
1073
 
@@ -928,7 +1077,8 @@ def _flush(span: Span) -> None:
928
1077
  Raises:
929
1078
  Exception: Whatever creating the worker or submitting raises; :func:`_end` is the guard.
930
1079
  """
931
- events, span.events = span.events, []
1080
+ with _sweep_lock:
1081
+ events, span.events = span.events, []
932
1082
  _get_worker().submit(events)
933
1083
 
934
1084
 
@@ -36,6 +36,18 @@ class Span:
36
36
  span, so a fire-and-forget ``create_task`` can outlive its parent and append to a buffer
37
37
  nothing will emit again. It is read by ``api._log`` at append time, which is the only place
38
38
  that can notice: nothing in the library looks at a span after ``_close_span`` returns.
39
+
40
+ ``swept`` marks a span whose buffered events an in-span ``flush()`` has already handed to the
41
+ worker (SPEC-036 FR-001). Nothing in the delivery path needs it — the sweep is correct without
42
+ it — but ``continue_trace`` does: ``_reparent_current_span`` adopts a context by rewriting the
43
+ events still *buffered* on the open root span, and swept events have left that buffer, so an
44
+ adoption after a sweep would leave one span carrying two trace ids. That is the SPEC-024
45
+ category, wrong data rather than lost data, so the flag lets the adoption refuse instead.
46
+
47
+ The guarantee is **single-threaded**. The flag is not a synchronization primitive:
48
+ ``continue_trace`` reads it and then re-parents across an ordinary function call, so a sweep
49
+ arriving in that gap still splits the span. It is set before the detach so a concurrent reader
50
+ errs toward refusing, and the residual is recorded in ``architecture.md`` §13.
39
51
  """
40
52
 
41
53
  trace_id: str
@@ -46,6 +58,7 @@ class Span:
46
58
  defaults: dict[str, object] = field(default_factory=dict)
47
59
  events: list[dict[str, object]] = field(default_factory=list)
48
60
  closed: bool = False
61
+ swept: bool = False
49
62
 
50
63
 
51
64
  def _iso_now() -> str: