struct-sdk 0.4.15__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.15 → struct_sdk-0.4.16}/PKG-INFO +9 -4
  2. {struct_sdk-0.4.15 → struct_sdk-0.4.16}/README.md +8 -3
  3. {struct_sdk-0.4.15 → struct_sdk-0.4.16}/pyproject.toml +1 -1
  4. {struct_sdk-0.4.15 → struct_sdk-0.4.16}/src/struct_sdk/_auto.py +144 -12
  5. {struct_sdk-0.4.15 → struct_sdk-0.4.16}/.gitignore +0 -0
  6. {struct_sdk-0.4.15 → struct_sdk-0.4.16}/LICENSE +0 -0
  7. {struct_sdk-0.4.15 → struct_sdk-0.4.16}/src/struct_sdk/__init__.py +0 -0
  8. {struct_sdk-0.4.15 → struct_sdk-0.4.16}/src/struct_sdk/_distro.py +0 -0
  9. {struct_sdk-0.4.15 → struct_sdk-0.4.16}/src/struct_sdk/_genai_content.py +0 -0
  10. {struct_sdk-0.4.15 → struct_sdk-0.4.16}/src/struct_sdk/_operation_details.py +0 -0
  11. {struct_sdk-0.4.15 → struct_sdk-0.4.16}/src/struct_sdk/ag2.py +0 -0
  12. {struct_sdk-0.4.15 → struct_sdk-0.4.16}/src/struct_sdk/anthropic.py +0 -0
  13. {struct_sdk-0.4.15 → struct_sdk-0.4.16}/src/struct_sdk/autogen.py +0 -0
  14. {struct_sdk-0.4.15 → struct_sdk-0.4.16}/src/struct_sdk/claude_agent.py +0 -0
  15. {struct_sdk-0.4.15 → struct_sdk-0.4.16}/src/struct_sdk/core.py +0 -0
  16. {struct_sdk-0.4.15 → struct_sdk-0.4.16}/src/struct_sdk/crewai.py +0 -0
  17. {struct_sdk-0.4.15 → struct_sdk-0.4.16}/src/struct_sdk/gcp.py +0 -0
  18. {struct_sdk-0.4.15 → struct_sdk-0.4.16}/src/struct_sdk/google_genai.py +0 -0
  19. {struct_sdk-0.4.15 → struct_sdk-0.4.16}/src/struct_sdk/langchain.py +0 -0
  20. {struct_sdk-0.4.15 → struct_sdk-0.4.16}/src/struct_sdk/litellm.py +0 -0
  21. {struct_sdk-0.4.15 → struct_sdk-0.4.16}/src/struct_sdk/llamaindex.py +0 -0
  22. {struct_sdk-0.4.15 → struct_sdk-0.4.16}/src/struct_sdk/mcp.py +0 -0
  23. {struct_sdk-0.4.15 → struct_sdk-0.4.16}/src/struct_sdk/openai.py +0 -0
  24. {struct_sdk-0.4.15 → struct_sdk-0.4.16}/src/struct_sdk/openai_agents.py +0 -0
  25. {struct_sdk-0.4.15 → struct_sdk-0.4.16}/src/struct_sdk/pydantic_ai.py +0 -0
  26. {struct_sdk-0.4.15 → 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.15
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
@@ -900,9 +900,14 @@ them.
900
900
  ### Datadog ddtrace
901
901
 
902
902
  Under `ddtrace-run`, Struct leaves the libraries ddtrace patches to it, and
903
- `urllib3` beneath `requests` or `botocore` (and `aiobotocore` when ddtrace
904
- patches `botocore`), to avoid duplicate spans and conflicting trace headers,
905
- and logs one INFO line listing them. Logs, process metrics, thread context, AI spans and libraries
903
+ `urllib3` beneath `requests` or `botocore`, to avoid duplicate spans and
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
906
911
  ddtrace doesn't patch still go to Struct. With `DD_TRACE_OTEL_ENABLED=true`,
907
912
  ddtrace's OpenTelemetry provider isn't the OpenTelemetry SDK, so full-app
908
913
  mode attaches nothing (an INFO line says so) and only AI spans reach Struct.
@@ -784,9 +784,14 @@ them.
784
784
  ### Datadog ddtrace
785
785
 
786
786
  Under `ddtrace-run`, Struct leaves the libraries ddtrace patches to it, and
787
- `urllib3` beneath `requests` or `botocore` (and `aiobotocore` when ddtrace
788
- patches `botocore`), to avoid duplicate spans and conflicting trace headers,
789
- and logs one INFO line listing them. Logs, process metrics, thread context, AI spans and libraries
787
+ `urllib3` beneath `requests` or `botocore`, to avoid duplicate spans and
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
790
795
  ddtrace doesn't patch still go to Struct. With `DD_TRACE_OTEL_ENABLED=true`,
791
796
  ddtrace's OpenTelemetry provider isn't the OpenTelemetry SDK, so full-app
792
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.15"
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"
@@ -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,10 +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. contrib's aiobotocore
136
- # instrumentor also wraps botocore's Endpoint.prepare_request, so it would write
137
- # Struct's X-Ray header on every botocore request ddtrace traces.
138
- _TRANSPORT_BENEATH = {"urllib3": ("requests", "botocore"), "aiobotocore": ("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()
139
169
 
140
170
  # Non-entry-point options handled here rather than by a contrib instrumentor.
141
171
  LOGGING = "logging"
@@ -305,18 +335,24 @@ def _instrument_libraries(
305
335
  available = {ep.name: ep for ep in _instrumentor_entry_points()}
306
336
  ddtrace_owned = _ddtrace_instrumented()
307
337
  left_to_ddtrace = []
338
+ beside_ddtrace: list[str] = []
308
339
  for name in SUPPORTED_INSTRUMENTATIONS:
309
340
  entry_point = available.get(name)
310
341
  if entry_point is None or not _enabled(options, name):
311
342
  continue
312
- 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:
313
345
  if _target_installed(entry_point):
314
346
  left_to_ddtrace.append(name)
315
347
  continue
316
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
317
351
 
318
- def instrument(ep: Any = entry_point, kw: dict[str, Any] = kwargs) -> None:
319
- _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)
320
356
 
321
357
  _safe(instrument, site=f"auto.instrument.{name}")
322
358
  if left_to_ddtrace:
@@ -325,6 +361,28 @@ def _instrument_libraries(
325
361
  "them, to avoid duplicate spans and conflicting trace headers.",
326
362
  ", ".join(sorted(left_to_ddtrace)),
327
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
+ )
328
386
 
329
387
 
330
388
  # The module a GenAI integration patches, where it differs from the integration's name.
@@ -348,6 +406,75 @@ def _genai_installed(name: str) -> bool:
348
406
  return False
349
407
 
350
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
+
351
478
  def _left_to_ddtrace(name: str, ddtrace_owned: set[str]) -> bool:
352
479
  if name in _NOT_SPAN_INSTRUMENTATIONS or not ddtrace_owned:
353
480
  return False
@@ -385,7 +512,9 @@ def _instrument_one(
385
512
  tracer_provider: TracerProvider,
386
513
  meter_provider: MeterProvider,
387
514
  kwargs: dict[str, Any],
388
- ) -> None:
515
+ quiet: bool = False,
516
+ ) -> bool:
517
+ """Whether Struct turned the instrumentor on."""
389
518
  from opentelemetry.instrumentation.dependencies import get_dist_dependency_conflicts
390
519
 
391
520
  # Checked before loading: BaseInstrumentor logs a missing target library at
@@ -393,13 +522,13 @@ def _instrument_one(
393
522
  conflict = get_dist_dependency_conflicts(entry_point.dist)
394
523
  if conflict is not None:
395
524
  logger.debug("Skipping %s instrumentation: %s", entry_point.name, conflict)
396
- return
525
+ return False
397
526
  instrumentor_class = entry_point.load()
398
527
  # Instrumentors are singletons whose constructor resets their config; check
399
528
  # the shared instance before re-initializing one the host already owns.
400
529
  instrumentor = instrumentor_class.__new__(instrumentor_class)
401
530
  if instrumentor.is_instrumented_by_opentelemetry:
402
- return
531
+ return False
403
532
  if entry_point.name == "system_metrics":
404
533
  instrumentor.__init__(config=_process_metrics_config())
405
534
  else:
@@ -413,8 +542,11 @@ def _instrument_one(
413
542
  if entry_point.name == "structlog" and not _guard_structlog_processor(instrumentor_class):
414
543
  instrumentor.uninstrument()
415
544
  logger.debug("Skipping structlog instrumentation: processor not found")
416
- return
545
+ return False
546
+ if quiet:
547
+ _quiet_aws_instrumentor(instrumentor)
417
548
  logger.info("Auto-instrumented: %s", entry_point.name)
549
+ return True
418
550
 
419
551
 
420
552
  def _guard_structlog_processor(instrumentor_class: Any) -> bool:
File without changes
File without changes