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.
- {struct_sdk-0.4.15 → struct_sdk-0.4.16}/PKG-INFO +9 -4
- {struct_sdk-0.4.15 → struct_sdk-0.4.16}/README.md +8 -3
- {struct_sdk-0.4.15 → struct_sdk-0.4.16}/pyproject.toml +1 -1
- {struct_sdk-0.4.15 → struct_sdk-0.4.16}/src/struct_sdk/_auto.py +144 -12
- {struct_sdk-0.4.15 → struct_sdk-0.4.16}/.gitignore +0 -0
- {struct_sdk-0.4.15 → struct_sdk-0.4.16}/LICENSE +0 -0
- {struct_sdk-0.4.15 → struct_sdk-0.4.16}/src/struct_sdk/__init__.py +0 -0
- {struct_sdk-0.4.15 → struct_sdk-0.4.16}/src/struct_sdk/_distro.py +0 -0
- {struct_sdk-0.4.15 → struct_sdk-0.4.16}/src/struct_sdk/_genai_content.py +0 -0
- {struct_sdk-0.4.15 → struct_sdk-0.4.16}/src/struct_sdk/_operation_details.py +0 -0
- {struct_sdk-0.4.15 → struct_sdk-0.4.16}/src/struct_sdk/ag2.py +0 -0
- {struct_sdk-0.4.15 → struct_sdk-0.4.16}/src/struct_sdk/anthropic.py +0 -0
- {struct_sdk-0.4.15 → struct_sdk-0.4.16}/src/struct_sdk/autogen.py +0 -0
- {struct_sdk-0.4.15 → struct_sdk-0.4.16}/src/struct_sdk/claude_agent.py +0 -0
- {struct_sdk-0.4.15 → struct_sdk-0.4.16}/src/struct_sdk/core.py +0 -0
- {struct_sdk-0.4.15 → struct_sdk-0.4.16}/src/struct_sdk/crewai.py +0 -0
- {struct_sdk-0.4.15 → struct_sdk-0.4.16}/src/struct_sdk/gcp.py +0 -0
- {struct_sdk-0.4.15 → struct_sdk-0.4.16}/src/struct_sdk/google_genai.py +0 -0
- {struct_sdk-0.4.15 → struct_sdk-0.4.16}/src/struct_sdk/langchain.py +0 -0
- {struct_sdk-0.4.15 → struct_sdk-0.4.16}/src/struct_sdk/litellm.py +0 -0
- {struct_sdk-0.4.15 → struct_sdk-0.4.16}/src/struct_sdk/llamaindex.py +0 -0
- {struct_sdk-0.4.15 → struct_sdk-0.4.16}/src/struct_sdk/mcp.py +0 -0
- {struct_sdk-0.4.15 → struct_sdk-0.4.16}/src/struct_sdk/openai.py +0 -0
- {struct_sdk-0.4.15 → struct_sdk-0.4.16}/src/struct_sdk/openai_agents.py +0 -0
- {struct_sdk-0.4.15 → struct_sdk-0.4.16}/src/struct_sdk/pydantic_ai.py +0 -0
- {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.
|
|
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
|
|
904
|
-
|
|
905
|
-
|
|
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
|
|
788
|
-
|
|
789
|
-
|
|
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.
|
|
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
|
|
136
|
-
#
|
|
137
|
-
#
|
|
138
|
-
_TRANSPORT_BENEATH = {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|