struct-sdk 0.4.14__tar.gz → 0.4.16__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 (26) hide show
  1. {struct_sdk-0.4.14 → struct_sdk-0.4.16}/PKG-INFO +14 -2
  2. {struct_sdk-0.4.14 → struct_sdk-0.4.16}/README.md +13 -1
  3. {struct_sdk-0.4.14 → struct_sdk-0.4.16}/pyproject.toml +10 -1
  4. {struct_sdk-0.4.14 → struct_sdk-0.4.16}/src/struct_sdk/_auto.py +144 -10
  5. {struct_sdk-0.4.14 → struct_sdk-0.4.16}/.gitignore +0 -0
  6. {struct_sdk-0.4.14 → struct_sdk-0.4.16}/LICENSE +0 -0
  7. {struct_sdk-0.4.14 → struct_sdk-0.4.16}/src/struct_sdk/__init__.py +0 -0
  8. {struct_sdk-0.4.14 → struct_sdk-0.4.16}/src/struct_sdk/_distro.py +0 -0
  9. {struct_sdk-0.4.14 → struct_sdk-0.4.16}/src/struct_sdk/_genai_content.py +0 -0
  10. {struct_sdk-0.4.14 → struct_sdk-0.4.16}/src/struct_sdk/_operation_details.py +0 -0
  11. {struct_sdk-0.4.14 → struct_sdk-0.4.16}/src/struct_sdk/ag2.py +0 -0
  12. {struct_sdk-0.4.14 → struct_sdk-0.4.16}/src/struct_sdk/anthropic.py +0 -0
  13. {struct_sdk-0.4.14 → struct_sdk-0.4.16}/src/struct_sdk/autogen.py +0 -0
  14. {struct_sdk-0.4.14 → struct_sdk-0.4.16}/src/struct_sdk/claude_agent.py +0 -0
  15. {struct_sdk-0.4.14 → struct_sdk-0.4.16}/src/struct_sdk/core.py +0 -0
  16. {struct_sdk-0.4.14 → struct_sdk-0.4.16}/src/struct_sdk/crewai.py +0 -0
  17. {struct_sdk-0.4.14 → struct_sdk-0.4.16}/src/struct_sdk/gcp.py +0 -0
  18. {struct_sdk-0.4.14 → struct_sdk-0.4.16}/src/struct_sdk/google_genai.py +0 -0
  19. {struct_sdk-0.4.14 → struct_sdk-0.4.16}/src/struct_sdk/langchain.py +0 -0
  20. {struct_sdk-0.4.14 → struct_sdk-0.4.16}/src/struct_sdk/litellm.py +0 -0
  21. {struct_sdk-0.4.14 → struct_sdk-0.4.16}/src/struct_sdk/llamaindex.py +0 -0
  22. {struct_sdk-0.4.14 → struct_sdk-0.4.16}/src/struct_sdk/mcp.py +0 -0
  23. {struct_sdk-0.4.14 → struct_sdk-0.4.16}/src/struct_sdk/openai.py +0 -0
  24. {struct_sdk-0.4.14 → struct_sdk-0.4.16}/src/struct_sdk/openai_agents.py +0 -0
  25. {struct_sdk-0.4.14 → struct_sdk-0.4.16}/src/struct_sdk/pydantic_ai.py +0 -0
  26. {struct_sdk-0.4.14 → struct_sdk-0.4.16}/src/struct_sdk/temporal.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: struct-sdk
3
- Version: 0.4.14
3
+ Version: 0.4.16
4
4
  Summary: Struct agent observability SDK — auto-instruments AI agent frameworks with OpenTelemetry
5
5
  Project-URL: Homepage, https://struct.ai
6
6
  Project-URL: Documentation, https://struct.ai/docs
@@ -862,6 +862,12 @@ before `init()`, call the contrib instrumentor directly (for example
862
862
  | Logs | Python `logging` records that reach the root logger, with trace context. The SDK's own and OpenTelemetry's records are excluded. With the `[structlog]` extra, structlog events that structlog writes itself (its default) are exported with their fields as attributes; events structlog hands to `logging` (`structlog.stdlib.LoggerFactory`) are exported once, through the `logging` bridge. Extras and structlog fields OpenTelemetry can't take as-is are exported as strings: dicts and mixed-type lists as JSON, everything else (UUIDs, datetimes, `None`, bytes) as its `str()`. Your other handlers still see the original values. With the `[exceptions]` extra, uncaught exceptions (main thread, other threads, asyncio) are exported as log records. If your app configures no logging, its warnings still print to stderr as they would without Struct. This is separate from `content_capture`, which only governs LLM content; disable `{"logging": {"enabled": False}}` and `{"structlog": {"enabled": False}}` (which also stops copies of your logs when Struct attaches to your OpenTelemetry setup), or redact attributes, if application logs can carry sensitive data. |
863
863
  | Metrics | Instrumentor metrics (for example `http.server.duration`) and process metrics (`process.cpu.*`, `process.memory.*`, threads, file descriptors, GC). Counters and histograms use delta temporality. |
864
864
 
865
+ AWS calls through boto3/botocore or aiobotocore become one span per API
866
+ operation (for example `S3.PutObject`), retries included, and carry the X-Ray
867
+ header AWS services propagate. When Struct attaches to your existing
868
+ OpenTelemetry setup, it instruments nothing, so AWS operation spans reach
869
+ Struct only if your setup instruments botocore itself.
870
+
865
871
  Each instrumentor activates only when its library is installed.
866
872
  `instrumentation_options` disables or configures one by name; other keys are
867
873
  passed to that instrumentor:
@@ -895,7 +901,13 @@ them.
895
901
 
896
902
  Under `ddtrace-run`, Struct leaves the libraries ddtrace patches to it, and
897
903
  `urllib3` beneath `requests` or `botocore`, to avoid duplicate spans and
898
- conflicting trace headers, and logs one INFO line listing them. Logs, process metrics, thread context, AI spans and libraries
904
+ conflicting trace headers, and logs one INFO line listing them. AWS clients
905
+ are the exception: Struct still records every botocore and aiobotocore
906
+ operation, and the AWS instrumentors it turns on propagate nothing (no trace
907
+ header, nothing in SNS message attributes or Lambda payloads), so Datadog's
908
+ propagation is unchanged. An AWS instrumentor your own setup turns on keeps its
909
+ propagator, and because both clients share botocore's request hook, its HTTP
910
+ headers reach the other client's requests too. Logs, process metrics, thread context, AI spans and libraries
899
911
  ddtrace doesn't patch still go to Struct. With `DD_TRACE_OTEL_ENABLED=true`,
900
912
  ddtrace's OpenTelemetry provider isn't the OpenTelemetry SDK, so full-app
901
913
  mode attaches nothing (an INFO line says so) and only AI spans reach Struct.
@@ -746,6 +746,12 @@ before `init()`, call the contrib instrumentor directly (for example
746
746
  | Logs | Python `logging` records that reach the root logger, with trace context. The SDK's own and OpenTelemetry's records are excluded. With the `[structlog]` extra, structlog events that structlog writes itself (its default) are exported with their fields as attributes; events structlog hands to `logging` (`structlog.stdlib.LoggerFactory`) are exported once, through the `logging` bridge. Extras and structlog fields OpenTelemetry can't take as-is are exported as strings: dicts and mixed-type lists as JSON, everything else (UUIDs, datetimes, `None`, bytes) as its `str()`. Your other handlers still see the original values. With the `[exceptions]` extra, uncaught exceptions (main thread, other threads, asyncio) are exported as log records. If your app configures no logging, its warnings still print to stderr as they would without Struct. This is separate from `content_capture`, which only governs LLM content; disable `{"logging": {"enabled": False}}` and `{"structlog": {"enabled": False}}` (which also stops copies of your logs when Struct attaches to your OpenTelemetry setup), or redact attributes, if application logs can carry sensitive data. |
747
747
  | Metrics | Instrumentor metrics (for example `http.server.duration`) and process metrics (`process.cpu.*`, `process.memory.*`, threads, file descriptors, GC). Counters and histograms use delta temporality. |
748
748
 
749
+ AWS calls through boto3/botocore or aiobotocore become one span per API
750
+ operation (for example `S3.PutObject`), retries included, and carry the X-Ray
751
+ header AWS services propagate. When Struct attaches to your existing
752
+ OpenTelemetry setup, it instruments nothing, so AWS operation spans reach
753
+ Struct only if your setup instruments botocore itself.
754
+
749
755
  Each instrumentor activates only when its library is installed.
750
756
  `instrumentation_options` disables or configures one by name; other keys are
751
757
  passed to that instrumentor:
@@ -779,7 +785,13 @@ them.
779
785
 
780
786
  Under `ddtrace-run`, Struct leaves the libraries ddtrace patches to it, and
781
787
  `urllib3` beneath `requests` or `botocore`, to avoid duplicate spans and
782
- conflicting trace headers, and logs one INFO line listing them. Logs, process metrics, thread context, AI spans and libraries
788
+ conflicting trace headers, and logs one INFO line listing them. AWS clients
789
+ are the exception: Struct still records every botocore and aiobotocore
790
+ operation, and the AWS instrumentors it turns on propagate nothing (no trace
791
+ header, nothing in SNS message attributes or Lambda payloads), so Datadog's
792
+ propagation is unchanged. An AWS instrumentor your own setup turns on keeps its
793
+ propagator, and because both clients share botocore's request hook, its HTTP
794
+ headers reach the other client's requests too. Logs, process metrics, thread context, AI spans and libraries
783
795
  ddtrace doesn't patch still go to Struct. With `DD_TRACE_OTEL_ENABLED=true`,
784
796
  ddtrace's OpenTelemetry provider isn't the OpenTelemetry SDK, so full-app
785
797
  mode attaches nothing (an INFO line says so) and only AI spans reach Struct.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "struct-sdk"
7
- version = "0.4.14"
7
+ version = "0.4.16"
8
8
  description = "Struct agent observability SDK — auto-instruments AI agent frameworks with OpenTelemetry"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -218,6 +218,15 @@ dev = [
218
218
  "google-cloud-bigquery>=3.30.0",
219
219
  "opentelemetry-instrumentation-grpc",
220
220
  "opentelemetry-propagator-b3",
221
+ # AWS client-operation suite (tests/test_aws_client_operations.py): real
222
+ # boto3 and aiobotocore clients, contrib's botocore instrumentor and the
223
+ # transport instrumentors beneath them (urllib3, aiohttp-client), against
224
+ # a local endpoint.
225
+ "boto3",
226
+ "aiobotocore",
227
+ "opentelemetry-instrumentation-botocore",
228
+ "opentelemetry-instrumentation-urllib3",
229
+ "opentelemetry-instrumentation-aiohttp-client",
221
230
  ]
222
231
  # The openai<3 suites: `uv run --no-default-groups --group openai2 pytest
223
232
  # tests/test_litellm.py tests/test_llamaindex.py` (pnpm run test:openai2).
@@ -32,7 +32,8 @@ from importlib.metadata import PackageNotFoundError, entry_points, version
32
32
  from typing import TYPE_CHECKING, Any, Callable, Mapping, Optional
33
33
 
34
34
  from opentelemetry import _logs, context, metrics, trace
35
- from opentelemetry.context import _SUPPRESS_INSTRUMENTATION_KEY
35
+ from opentelemetry.context import _SUPPRESS_INSTRUMENTATION_KEY, Context
36
+ from opentelemetry.propagators.textmap import TextMapPropagator
36
37
  from opentelemetry.sdk._logs import LoggerProvider, LogRecordProcessor
37
38
  from opentelemetry.sdk.metrics import MeterProvider
38
39
  from opentelemetry.sdk.trace import SpanProcessor, TracerProvider
@@ -132,8 +133,39 @@ _DDTRACE_INTEGRATIONS = {
132
133
  }
133
134
 
134
135
  # Transports beneath clients ddtrace patches: instrumenting one would overwrite
135
- # the traceparent ddtrace wrote on the same request.
136
- _TRANSPORT_BENEATH = {"urllib3": ("requests", "botocore")}
136
+ # the traceparent ddtrace wrote on the same request. contrib's botocore and
137
+ # aiobotocore instrumentors both write their X-Ray header in botocore's shared
138
+ # Endpoint.prepare_request, so either one touches the other's requests.
139
+ _TRANSPORT_BENEATH = {
140
+ "urllib3": ("requests", "botocore"),
141
+ "aiobotocore": ("botocore",),
142
+ "botocore": ("aiobotocore",),
143
+ }
144
+
145
+ # Next to ddtrace these still record spans but propagate nothing, so Datadog's
146
+ # context stays the only one the request carries.
147
+ _QUIET_NEXT_TO_DDTRACE = frozenset({"botocore", "aiobotocore"})
148
+
149
+ # Services whose contrib extensions write the global propagator's context into
150
+ # the Lambda Invoke payload or SNS message attributes in before_service_call,
151
+ # outside the instrumentor's propagator; that is all their before_service_call
152
+ # does.
153
+ _AWS_MESSAGE_PROPAGATING_SERVICES = frozenset({"lambda", "sns"})
154
+
155
+
156
+ class _NoPropagation(TextMapPropagator):
157
+ """Writes nothing and reads nothing: a quiet AWS instrumentor's fallback when
158
+ contrib's request hook is not where ``instrument()`` puts it."""
159
+
160
+ def extract(self, carrier: Any, context: Optional[Context] = None, getter: Any = None) -> Context:
161
+ return context if context is not None else Context()
162
+
163
+ def inject(self, carrier: Any, context: Optional[Context] = None, setter: Any = None) -> None:
164
+ pass
165
+
166
+ @property
167
+ def fields(self) -> set[str]:
168
+ return set()
137
169
 
138
170
  # Non-entry-point options handled here rather than by a contrib instrumentor.
139
171
  LOGGING = "logging"
@@ -303,18 +335,24 @@ def _instrument_libraries(
303
335
  available = {ep.name: ep for ep in _instrumentor_entry_points()}
304
336
  ddtrace_owned = _ddtrace_instrumented()
305
337
  left_to_ddtrace = []
338
+ beside_ddtrace: list[str] = []
306
339
  for name in SUPPORTED_INSTRUMENTATIONS:
307
340
  entry_point = available.get(name)
308
341
  if entry_point is None or not _enabled(options, name):
309
342
  continue
310
- if _left_to_ddtrace(name, ddtrace_owned):
343
+ owned_by_ddtrace = _left_to_ddtrace(name, ddtrace_owned)
344
+ if owned_by_ddtrace and name not in _QUIET_NEXT_TO_DDTRACE:
311
345
  if _target_installed(entry_point):
312
346
  left_to_ddtrace.append(name)
313
347
  continue
314
348
  kwargs = {k: v for k, v in options.get(name, {}).items() if k != "enabled"}
349
+ # A propagator the app chose wins, and so does its propagation.
350
+ quiet = owned_by_ddtrace and "propagator" not in kwargs
315
351
 
316
- def instrument(ep: Any = entry_point, kw: dict[str, Any] = kwargs) -> None:
317
- _instrument_one(ep, tracer_provider, meter_provider, kw)
352
+ def instrument(ep: Any = entry_point, kw: dict[str, Any] = kwargs, q: bool = quiet) -> None:
353
+ # Reported only once Struct turned it on: a host's own instrumentor is kept as it is.
354
+ if _instrument_one(ep, tracer_provider, meter_provider, kw, quiet=q) and q:
355
+ beside_ddtrace.append(ep.name)
318
356
 
319
357
  _safe(instrument, site=f"auto.instrument.{name}")
320
358
  if left_to_ddtrace:
@@ -323,6 +361,28 @@ def _instrument_libraries(
323
361
  "them, to avoid duplicate spans and conflicting trace headers.",
324
362
  ", ".join(sorted(left_to_ddtrace)),
325
363
  )
364
+ # contrib's two AWS instrumentors share botocore's request hook, so another
365
+ # one that propagates, with an app-set propagator or the host's own, also
366
+ # writes the quiet client's HTTP headers.
367
+ propagating = [
368
+ name for name in sorted(_QUIET_NEXT_TO_DDTRACE - set(beside_ddtrace))
369
+ if beside_ddtrace and _aws_instrumentor_propagates(available.get(name))
370
+ ]
371
+ if propagating:
372
+ logger.info(
373
+ "Struct traces %s alongside Datadog ddtrace without writing its trace context into "
374
+ "SNS message attributes or Lambda payloads; its HTTP requests carry the headers %s's "
375
+ "instrumentor writes through the request hook they share.",
376
+ ", ".join(sorted(beside_ddtrace)),
377
+ ", ".join(propagating),
378
+ )
379
+ elif beside_ddtrace:
380
+ logger.info(
381
+ "Struct traces %s alongside Datadog ddtrace without propagating its trace context "
382
+ "(HTTP headers, SNS message attributes, Lambda payloads); a propagator in "
383
+ "instrumentation_options turns it back on.",
384
+ ", ".join(sorted(beside_ddtrace)),
385
+ )
326
386
 
327
387
 
328
388
  # The module a GenAI integration patches, where it differs from the integration's name.
@@ -346,6 +406,75 @@ def _genai_installed(name: str) -> bool:
346
406
  return False
347
407
 
348
408
 
409
+ def _aws_instrumentor_propagates(entry_point: Any) -> bool:
410
+ """Whether this AWS instrumentor, not one Struct turned on quietly, is on
411
+ with a propagator that writes headers."""
412
+ if entry_point is None or not _target_installed(entry_point):
413
+ return False
414
+ try:
415
+ instrumentor_class = entry_point.load()
416
+ except Exception:
417
+ return False
418
+ instrumentor = instrumentor_class.__new__(instrumentor_class)
419
+ return bool(instrumentor.is_instrumented_by_opentelemetry) and bool(
420
+ getattr(getattr(instrumentor, "propagator", None), "fields", ())
421
+ )
422
+
423
+
424
+ def _skip_before_service_call(*args: Any, **kwargs: Any) -> None:
425
+ pass
426
+
427
+
428
+ def _quiet_aws_instrumentor(instrumentor: Any) -> None:
429
+ """Make an AWS instrumentor Struct just turned on propagate nothing.
430
+
431
+ contrib's request hook on botocore's shared ``Endpoint.prepare_request``
432
+ only writes the X-Ray header, so it is removed: it is the outermost layer,
433
+ just added by ``instrument()``. The instrumentor's own propagator is left
434
+ as it is, so nothing has to be restored later and no hook can be left
435
+ using a removed one. If the hook is not there, a propagator that writes
436
+ nothing is the fallback.
437
+
438
+ Its Lambda and SNS extensions skip before_service_call, which only writes
439
+ context into the payload or message attributes: the payload goes out as the
440
+ app wrote it, and nothing is logged about attribute limits. Only this
441
+ instrumentor's own registry changes, so the other AWS client, the host's
442
+ global propagator, and a later instrument() (which builds a new registry)
443
+ are untouched."""
444
+ import wrapt
445
+ from botocore.endpoint import Endpoint # type: ignore[import-untyped]
446
+
447
+ hook = Endpoint.__dict__.get("prepare_request")
448
+ if isinstance(hook, wrapt.FunctionWrapper) and getattr(hook._self_wrapper, "__self__", None) is instrumentor:
449
+ Endpoint.prepare_request = hook.__wrapped__
450
+ else:
451
+ instrumentor.propagator = _NoPropagation()
452
+
453
+ registry = instrumentor.extension_registry
454
+ get_extension = registry.get_extension
455
+
456
+ def quiet_get_extension(call_context: Any) -> Any:
457
+ extension = get_extension(call_context)
458
+ if call_context.service in _AWS_MESSAGE_PROPAGATING_SERVICES:
459
+ extension.before_service_call = _skip_before_service_call
460
+ return extension
461
+
462
+ registry.get_extension = quiet_get_extension
463
+ uninstrument = instrumentor.uninstrument
464
+
465
+ def uninstrument_keeping_request_hooks(**kwargs: Any) -> Any:
466
+ kept = Endpoint.__dict__.get("prepare_request")
467
+ try:
468
+ return uninstrument(**kwargs)
469
+ finally:
470
+ instrumentor.__dict__.pop("uninstrument", None)
471
+ # contrib's uninstrument() unwraps the outermost request hook, and
472
+ # this instrumentor no longer has one: put back what was there.
473
+ Endpoint.prepare_request = kept
474
+
475
+ instrumentor.uninstrument = uninstrument_keeping_request_hooks
476
+
477
+
349
478
  def _left_to_ddtrace(name: str, ddtrace_owned: set[str]) -> bool:
350
479
  if name in _NOT_SPAN_INSTRUMENTATIONS or not ddtrace_owned:
351
480
  return False
@@ -383,7 +512,9 @@ def _instrument_one(
383
512
  tracer_provider: TracerProvider,
384
513
  meter_provider: MeterProvider,
385
514
  kwargs: dict[str, Any],
386
- ) -> None:
515
+ quiet: bool = False,
516
+ ) -> bool:
517
+ """Whether Struct turned the instrumentor on."""
387
518
  from opentelemetry.instrumentation.dependencies import get_dist_dependency_conflicts
388
519
 
389
520
  # Checked before loading: BaseInstrumentor logs a missing target library at
@@ -391,13 +522,13 @@ def _instrument_one(
391
522
  conflict = get_dist_dependency_conflicts(entry_point.dist)
392
523
  if conflict is not None:
393
524
  logger.debug("Skipping %s instrumentation: %s", entry_point.name, conflict)
394
- return
525
+ return False
395
526
  instrumentor_class = entry_point.load()
396
527
  # Instrumentors are singletons whose constructor resets their config; check
397
528
  # the shared instance before re-initializing one the host already owns.
398
529
  instrumentor = instrumentor_class.__new__(instrumentor_class)
399
530
  if instrumentor.is_instrumented_by_opentelemetry:
400
- return
531
+ return False
401
532
  if entry_point.name == "system_metrics":
402
533
  instrumentor.__init__(config=_process_metrics_config())
403
534
  else:
@@ -411,8 +542,11 @@ def _instrument_one(
411
542
  if entry_point.name == "structlog" and not _guard_structlog_processor(instrumentor_class):
412
543
  instrumentor.uninstrument()
413
544
  logger.debug("Skipping structlog instrumentation: processor not found")
414
- return
545
+ return False
546
+ if quiet:
547
+ _quiet_aws_instrumentor(instrumentor)
415
548
  logger.info("Auto-instrumented: %s", entry_point.name)
549
+ return True
416
550
 
417
551
 
418
552
  def _guard_structlog_processor(instrumentor_class: Any) -> bool:
File without changes
File without changes