wellapi 0.10.1__tar.gz → 0.10.2__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. {wellapi-0.10.1 → wellapi-0.10.2}/PKG-INFO +1 -1
  2. {wellapi-0.10.1 → wellapi-0.10.2}/docs/telemetry.md +30 -11
  3. {wellapi-0.10.1 → wellapi-0.10.2}/pyproject.toml +1 -1
  4. {wellapi-0.10.1 → wellapi-0.10.2}/src/wellapi/telemetry/config.py +36 -1
  5. {wellapi-0.10.1 → wellapi-0.10.2}/src/wellapi/telemetry/middleware.py +3 -12
  6. {wellapi-0.10.1 → wellapi-0.10.2}/tests/conftest.py +0 -13
  7. {wellapi-0.10.1 → wellapi-0.10.2}/tests/telemetry/test_config.py +39 -0
  8. {wellapi-0.10.1 → wellapi-0.10.2}/tests/telemetry/test_middleware.py +3 -35
  9. {wellapi-0.10.1 → wellapi-0.10.2}/uv.lock +1 -1
  10. {wellapi-0.10.1 → wellapi-0.10.2}/.github/workflows/build.pipeline.yml +0 -0
  11. {wellapi-0.10.1 → wellapi-0.10.2}/.gitignore +0 -0
  12. {wellapi-0.10.1 → wellapi-0.10.2}/.python-version +0 -0
  13. {wellapi-0.10.1 → wellapi-0.10.2}/README.md +0 -0
  14. {wellapi-0.10.1 → wellapi-0.10.2}/docs/framework-usage.md +0 -0
  15. {wellapi-0.10.1 → wellapi-0.10.2}/docs/superpowers/plans/2026-05-29-otel-native-telemetry.md +0 -0
  16. {wellapi-0.10.1 → wellapi-0.10.2}/docs/superpowers/specs/2026-05-29-otel-native-telemetry-design.md +0 -0
  17. {wellapi-0.10.1 → wellapi-0.10.2}/src/wellapi/__init__.py +0 -0
  18. {wellapi-0.10.1 → wellapi-0.10.2}/src/wellapi/__main__.py +0 -0
  19. {wellapi-0.10.1 → wellapi-0.10.2}/src/wellapi/applications.py +0 -0
  20. {wellapi-0.10.1 → wellapi-0.10.2}/src/wellapi/awsmodel.py +0 -0
  21. {wellapi-0.10.1 → wellapi-0.10.2}/src/wellapi/build/__init__.py +0 -0
  22. {wellapi-0.10.1 → wellapi-0.10.2}/src/wellapi/build/cdk.py +0 -0
  23. {wellapi-0.10.1 → wellapi-0.10.2}/src/wellapi/build/packager.py +0 -0
  24. {wellapi-0.10.1 → wellapi-0.10.2}/src/wellapi/cli/__init__.py +0 -0
  25. {wellapi-0.10.1 → wellapi-0.10.2}/src/wellapi/cli/main.py +0 -0
  26. {wellapi-0.10.1 → wellapi-0.10.2}/src/wellapi/convertors.py +0 -0
  27. {wellapi-0.10.1 → wellapi-0.10.2}/src/wellapi/datastructures.py +0 -0
  28. {wellapi-0.10.1 → wellapi-0.10.2}/src/wellapi/dependencies/__init__.py +0 -0
  29. {wellapi-0.10.1 → wellapi-0.10.2}/src/wellapi/dependencies/models.py +0 -0
  30. {wellapi-0.10.1 → wellapi-0.10.2}/src/wellapi/dependencies/utils.py +0 -0
  31. {wellapi-0.10.1 → wellapi-0.10.2}/src/wellapi/exception_handlers.py +0 -0
  32. {wellapi-0.10.1 → wellapi-0.10.2}/src/wellapi/exceptions.py +0 -0
  33. {wellapi-0.10.1 → wellapi-0.10.2}/src/wellapi/local/__init__.py +0 -0
  34. {wellapi-0.10.1 → wellapi-0.10.2}/src/wellapi/local/router.py +0 -0
  35. {wellapi-0.10.1 → wellapi-0.10.2}/src/wellapi/local/server.py +0 -0
  36. {wellapi-0.10.1 → wellapi-0.10.2}/src/wellapi/middleware/__init__.py +0 -0
  37. {wellapi-0.10.1 → wellapi-0.10.2}/src/wellapi/middleware/base.py +0 -0
  38. {wellapi-0.10.1 → wellapi-0.10.2}/src/wellapi/middleware/cors.py +0 -0
  39. {wellapi-0.10.1 → wellapi-0.10.2}/src/wellapi/middleware/error.py +0 -0
  40. {wellapi-0.10.1 → wellapi-0.10.2}/src/wellapi/middleware/exceptions.py +0 -0
  41. {wellapi-0.10.1 → wellapi-0.10.2}/src/wellapi/middleware/main.py +0 -0
  42. {wellapi-0.10.1 → wellapi-0.10.2}/src/wellapi/models.py +0 -0
  43. {wellapi-0.10.1 → wellapi-0.10.2}/src/wellapi/openapi/__init__.py +0 -0
  44. {wellapi-0.10.1 → wellapi-0.10.2}/src/wellapi/openapi/docs.py +0 -0
  45. {wellapi-0.10.1 → wellapi-0.10.2}/src/wellapi/openapi/models.py +0 -0
  46. {wellapi-0.10.1 → wellapi-0.10.2}/src/wellapi/openapi/security_model.py +0 -0
  47. {wellapi-0.10.1 → wellapi-0.10.2}/src/wellapi/openapi/utils.py +0 -0
  48. {wellapi-0.10.1 → wellapi-0.10.2}/src/wellapi/params.py +0 -0
  49. {wellapi-0.10.1 → wellapi-0.10.2}/src/wellapi/routing.py +0 -0
  50. {wellapi-0.10.1 → wellapi-0.10.2}/src/wellapi/security.py +0 -0
  51. {wellapi-0.10.1 → wellapi-0.10.2}/src/wellapi/telemetry/__init__.py +0 -0
  52. {wellapi-0.10.1 → wellapi-0.10.2}/src/wellapi/telemetry/attributes.py +0 -0
  53. {wellapi-0.10.1 → wellapi-0.10.2}/src/wellapi/telemetry/flush.py +0 -0
  54. {wellapi-0.10.1 → wellapi-0.10.2}/src/wellapi/testclient.py +0 -0
  55. {wellapi-0.10.1 → wellapi-0.10.2}/src/wellapi/utils.py +0 -0
  56. {wellapi-0.10.1 → wellapi-0.10.2}/tests/telemetry/__init__.py +0 -0
  57. {wellapi-0.10.1 → wellapi-0.10.2}/tests/telemetry/test_applications.py +0 -0
  58. {wellapi-0.10.1 → wellapi-0.10.2}/tests/telemetry/test_attributes.py +0 -0
  59. {wellapi-0.10.1 → wellapi-0.10.2}/tests/telemetry/test_flush.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wellapi
3
- Version: 0.10.1
3
+ Version: 0.10.2
4
4
  Summary: A simple web framework for aws lambda
5
5
  Author-email: romayuhym <romayuhym@gmail.com>
6
6
  Requires-Python: >=3.12
@@ -74,17 +74,6 @@ SERVER GET /orders/{id}
74
74
  Call `use_telemetry()` before `instrument()`. (Reverse order also works — OTel's
75
75
  `ProxyTracer` resolves to the global provider at span-creation time.)
76
76
 
77
- ## Trace context propagation
78
-
79
- Every invocation starts its **own** trace. The inbound W3C trace context — the
80
- `traceparent` header on API Gateway requests, or the `traceparent` SQS message
81
- attribute — is attached to the root span as a **span link**, not used as its
82
- parent. This is deliberate: a sticky or shared upstream `traceparent` (e.g. a
83
- client that reuses one trace across many calls, or a producer that fans out many
84
- messages within a single trace) would otherwise collapse unrelated invocations
85
- into a single `trace_id`. Linking instead of parenting keeps each invocation a
86
- distinct trace while preserving the correlation back to the caller.
87
-
88
77
  ## Logs
89
78
 
90
79
  wellapi configures and flushes a `LoggerProvider` but does not attach handlers —
@@ -118,6 +107,36 @@ to the function name).
118
107
  `OTEL_SERVICE_NAME` and `OTEL_RESOURCE_ATTRIBUTES` override these without code
119
108
  changes.
120
109
 
110
+ ## SnapStart and ID uniqueness
111
+
112
+ When `warmup`/`use_snap_start` is on, the function is published with AWS Lambda
113
+ SnapStart: Lambda boots one environment, snapshots its memory, and restores that
114
+ **same** snapshot into every execution environment. Anything seeded once at init
115
+ is therefore shared by all restored environments.
116
+
117
+ OpenTelemetry's default ID generator draws trace/span IDs from Python's
118
+ module-level `random` (a Mersenne Twister seeded once at import). Under SnapStart
119
+ that frozen state is cloned, so every restored environment emits the **same
120
+ sequence** of trace/span IDs — you see one `trace_id` reused across unrelated
121
+ requests. wellapi avoids this by configuring the tracer provider with a
122
+ `SystemRandom` (`os.urandom`-backed) ID generator: the kernel CSPRNG is reseeded
123
+ with fresh entropy on restore, so IDs stay unique. No action needed for telemetry.
124
+
125
+ If your own handlers (or other libraries) rely on the `random` module, `uuid1`,
126
+ or any non-CSPRNG source for uniqueness, they have the same SnapStart hazard.
127
+ Reseed them in an after-restore hook:
128
+
129
+ ```python
130
+ import random
131
+ from snapshot_restore_py import register_after_restore
132
+
133
+ @register_after_restore
134
+ def _reseed():
135
+ random.seed() # pull fresh entropy from the OS after restore
136
+ ```
137
+
138
+ `os.urandom`, `secrets`, and `uuid4` read the kernel CSPRNG and are already safe.
139
+
121
140
  ## Local development
122
141
 
123
142
  Telemetry is off until `use_telemetry()` is called. If you call it locally without
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "wellapi"
3
- version = "0.10.1"
3
+ version = "0.10.2"
4
4
  description = "A simple web framework for aws lambda"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -1,4 +1,5 @@
1
1
  import os
2
+ import random
2
3
  from dataclasses import dataclass
3
4
  from typing import Any
4
5
 
@@ -13,6 +14,37 @@ _INSTALL_HINT = (
13
14
  )
14
15
 
15
16
 
17
+ class _SystemRandomIdGenerator:
18
+ """Trace/span ID generator backed by the kernel CSPRNG (`os.urandom`).
19
+
20
+ OpenTelemetry's default `RandomIdGenerator` draws IDs from the module-level
21
+ Mersenne Twister, which Python seeds once at import. AWS Lambda SnapStart
22
+ captures that PRNG state in the snapshot and shares it across every restored
23
+ execution environment, so the default generator emits identical trace/span
24
+ IDs in lockstep across environments — observed as duplicate `trace_id`s on
25
+ unrelated requests. The kernel CSPRNG is reseeded with fresh entropy on
26
+ restore, so `random.SystemRandom` (i.e. `os.urandom`) stays unique. Duck-typed
27
+ against `opentelemetry.sdk.trace.id_generator.IdGenerator` to avoid importing
28
+ the SDK at module import time (kept lazy, like the rest of this module)."""
29
+
30
+ _rand = random.SystemRandom()
31
+
32
+ def generate_span_id(self) -> int:
33
+ span_id = self._rand.getrandbits(64)
34
+ while span_id == trace.INVALID_SPAN_ID:
35
+ span_id = self._rand.getrandbits(64)
36
+ return span_id
37
+
38
+ def generate_trace_id(self) -> int:
39
+ trace_id = self._rand.getrandbits(128)
40
+ while trace_id == trace.INVALID_TRACE_ID:
41
+ trace_id = self._rand.getrandbits(128)
42
+ return trace_id
43
+
44
+ def is_trace_id_random(self) -> bool:
45
+ return True
46
+
47
+
16
48
  @dataclass
17
49
  class TelemetryHandle:
18
50
  """Handles to the configured providers. Returned by `app.use_telemetry()` so
@@ -73,7 +105,10 @@ def _build_providers(resource: Any) -> tuple[Any, Any, Any]:
73
105
 
74
106
  # Exporters default to OTEL_EXPORTER_OTLP_ENDPOINT or http://localhost:4318,
75
107
  # i.e. the collector-only Lambda layer running on localhost.
76
- tracer_provider = TracerProvider(resource=resource)
108
+ # _SystemRandomIdGenerator keeps IDs unique under SnapStart (see its docstring).
109
+ tracer_provider = TracerProvider(
110
+ resource=resource, id_generator=_SystemRandomIdGenerator()
111
+ )
77
112
  tracer_provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter()))
78
113
 
79
114
  meter_provider = MeterProvider(
@@ -4,8 +4,7 @@ import typing
4
4
 
5
5
  try:
6
6
  from opentelemetry import propagate
7
- from opentelemetry.context import Context
8
- from opentelemetry.trace import Link, Span, SpanKind, get_current_span
7
+ from opentelemetry.trace import Span, SpanKind
9
8
  from opentelemetry.trace.status import Status, StatusCode
10
9
  except ImportError as err: # pragma: no cover
11
10
  raise RuntimeError(
@@ -68,14 +67,7 @@ class TelemetryMiddleware:
68
67
  start = time.perf_counter()
69
68
 
70
69
  attribute = get_request_attribute(request)
71
- # Each invocation starts its OWN trace (root span). The inbound trace
72
- # context is attached as a link, not used as the parent: a sticky or
73
- # shared upstream traceparent would otherwise collapse unrelated
74
- # invocations into a single trace_id (warm-container fan-in).
75
- upstream = get_current_span(
76
- propagate.extract(get_trace_carrier(request))
77
- ).get_span_context()
78
- links = [Link(upstream)] if upstream.is_valid else None
70
+ parent = propagate.extract(get_trace_carrier(request))
79
71
  span_attributes = {
80
72
  **attribute.attributes,
81
73
  **get_code_attribute(),
@@ -86,10 +78,9 @@ class TelemetryMiddleware:
86
78
  response: ResponseAPIGateway | None = None
87
79
  with self.tracer.start_as_current_span(
88
80
  attribute.span_name,
89
- context=Context(),
81
+ context=parent,
90
82
  kind=_SPAN_KIND.get(attribute.kind, SpanKind.SERVER),
91
83
  attributes=span_attributes,
92
- links=links,
93
84
  ) as span:
94
85
  if self.request_hook:
95
86
  self.request_hook(span, request)
@@ -83,24 +83,11 @@ def job_event():
83
83
  }
84
84
 
85
85
 
86
- @pytest.fixture
87
- def api_gateway_event_no_trace(api_gateway_event):
88
- event = dict(api_gateway_event)
89
- event["headers"] = {"Host": "api.example.com"}
90
- event["multiValueHeaders"] = {"Host": ["api.example.com"]}
91
- return event
92
-
93
-
94
86
  @pytest.fixture
95
87
  def api_gateway_request(api_gateway_event):
96
88
  return RequestAPIGateway.create_request_from_event(api_gateway_event)
97
89
 
98
90
 
99
- @pytest.fixture
100
- def api_gateway_request_no_trace(api_gateway_event_no_trace):
101
- return RequestAPIGateway.create_request_from_event(api_gateway_event_no_trace)
102
-
103
-
104
91
  @pytest.fixture
105
92
  def sqs_request(sqs_event):
106
93
  return RequestSQS.create_request_from_event(sqs_event)
@@ -1,3 +1,5 @@
1
+ import random
2
+
1
3
  import wellapi.telemetry.config as config_mod
2
4
  from wellapi.telemetry.config import TelemetryHandle, _build_resource
3
5
 
@@ -6,6 +8,40 @@ def _attrs(resource):
6
8
  return dict(resource.attributes)
7
9
 
8
10
 
11
+ def _ids_after_seed(generator, seed):
12
+ """IDs a freshly snapshot-restored env would emit: identical frozen
13
+ module-level random state (the AWS SnapStart failure mode)."""
14
+ state = random.getstate()
15
+ try:
16
+ random.seed(seed)
17
+ return generator.generate_trace_id(), generator.generate_span_id()
18
+ finally:
19
+ random.setstate(state)
20
+
21
+
22
+ def test_default_random_id_generator_collides_under_cloned_random_state():
23
+ # Documents the bug: the OTel default generator draws from the module-level
24
+ # Mersenne Twister, which SnapStart freezes and shares across restored envs.
25
+ from opentelemetry.sdk.trace.id_generator import RandomIdGenerator
26
+
27
+ gen = RandomIdGenerator()
28
+ assert _ids_after_seed(gen, 1234) == _ids_after_seed(gen, 1234)
29
+
30
+
31
+ def test_snapstart_safe_id_generator_survives_cloned_random_state():
32
+ gen = config_mod._SystemRandomIdGenerator()
33
+ # Two envs restored from the same snapshot (same frozen MT state) must still
34
+ # produce distinct trace/span IDs.
35
+ assert _ids_after_seed(gen, 1234) != _ids_after_seed(gen, 1234)
36
+
37
+
38
+ def test_snapstart_safe_id_generator_marks_trace_id_random():
39
+ gen = config_mod._SystemRandomIdGenerator()
40
+ assert gen.is_trace_id_random() is True
41
+ assert 0 < gen.generate_trace_id() < 2**128
42
+ assert 0 < gen.generate_span_id() < 2**64
43
+
44
+
9
45
  def test_resource_uses_faas_env(monkeypatch):
10
46
  monkeypatch.setenv("AWS_LAMBDA_FUNCTION_NAME", "orders-fn")
11
47
  monkeypatch.setenv("AWS_REGION", "eu-west-1")
@@ -53,6 +89,9 @@ def test_build_providers_attaches_resource_and_returns_sdk_types():
53
89
  assert isinstance(mp, MeterProvider)
54
90
  assert isinstance(lp, LoggerProvider)
55
91
  assert dict(tp.resource.attributes)["service.name"] == "t"
92
+ # SnapStart-safe IDs: the tracer provider must not use the default MT-backed
93
+ # generator, otherwise restored envs emit duplicate trace/span IDs.
94
+ assert isinstance(tp.id_generator, config_mod._SystemRandomIdGenerator)
56
95
 
57
96
 
58
97
  def test_configure_reuses_existing_sdk_provider(monkeypatch):
@@ -63,7 +63,7 @@ def test_span_is_active_in_context(telemetry, api_gateway_request, monkeypatch):
63
63
  assert captured["ctx"].span_id == span.context.span_id
64
64
 
65
65
 
66
- def test_inbound_traceparent_becomes_link_not_parent(
66
+ def test_parent_context_extracted_from_traceparent(
67
67
  telemetry, api_gateway_request, monkeypatch
68
68
  ):
69
69
  handle, exporter, _ = telemetry
@@ -72,40 +72,8 @@ def test_inbound_traceparent_becomes_link_not_parent(
72
72
  api_gateway_request
73
73
  )
74
74
  (span,) = exporter.get_finished_spans()
75
- # The span starts its own trace instead of inheriting a (possibly sticky)
76
- # upstream traceparent that would collapse unrelated requests into one trace.
77
- assert span.context.trace_id != TRACE_ID
78
- assert span.parent is None
79
- # The upstream context is preserved as a link for cross-trace correlation.
80
- (link,) = span.links
81
- assert link.context.trace_id == TRACE_ID
82
- assert link.context.span_id == PARENT_SPAN_ID
83
-
84
-
85
- def test_two_invocations_with_same_traceparent_get_distinct_traces(
86
- telemetry, api_gateway_request, monkeypatch
87
- ):
88
- handle, exporter, _ = telemetry
89
- monkeypatch.setattr(mw_mod, "force_flush", lambda *a, **k: None)
90
- mw = TelemetryMiddleware(lambda req: ResponseAPIGateway(status_code=200), handle)
91
- mw(api_gateway_request)
92
- mw(api_gateway_request)
93
- trace_ids = {s.context.trace_id for s in exporter.get_finished_spans()}
94
- assert len(trace_ids) == 2
95
- assert TRACE_ID not in trace_ids
96
-
97
-
98
- def test_no_inbound_traceparent_starts_root_without_link(
99
- telemetry, api_gateway_request_no_trace, monkeypatch
100
- ):
101
- handle, exporter, _ = telemetry
102
- monkeypatch.setattr(mw_mod, "force_flush", lambda *a, **k: None)
103
- TelemetryMiddleware(lambda req: ResponseAPIGateway(status_code=200), handle)(
104
- api_gateway_request_no_trace
105
- )
106
- (span,) = exporter.get_finished_spans()
107
- assert span.parent is None
108
- assert span.links == ()
75
+ assert span.context.trace_id == TRACE_ID
76
+ assert span.parent.span_id == PARENT_SPAN_ID
109
77
 
110
78
 
111
79
  def test_exception_sets_error_status_and_reraises(
@@ -672,7 +672,7 @@ wheels = [
672
672
 
673
673
  [[package]]
674
674
  name = "wellapi"
675
- version = "0.10.1"
675
+ version = "0.10.2"
676
676
  source = { editable = "." }
677
677
  dependencies = [
678
678
  { name = "email-validator" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes